From a724719f4b22c6eaa4e931017765517e60c596d7 Mon Sep 17 00:00:00 2001 From: qaz741wsd856 Date: Thu, 4 Dec 2025 17:54:16 +0000 Subject: [PATCH] refactor: replace js wrapper with rust binding --- README.md | 17 ++-- src/error.rs | 4 + src/handlers/accounts.rs | 41 ++++++++- src/handlers/identity.rs | 13 +++ src/rate-limit-wrapper.js | 173 -------------------------------------- wrangler.toml | 2 +- 6 files changed, 67 insertions(+), 183 deletions(-) delete mode 100644 src/rate-limit-wrapper.js diff --git a/README.md b/README.md index 5cf4e31..6cc4db7 100644 --- a/README.md +++ b/README.md @@ -224,18 +224,19 @@ This project includes **built-in rate limiting** powered by [Cloudflare's Rate L #### Protected Endpoints -| Endpoint | Rate Limit | Key Type | -|----------|------------|----------| -| `/identity/connect/token` | 5 req/min | Email address | -| `/api/accounts/register` | 5 req/min | IP address | -| `/api/accounts/prelogin` | 5 req/min | IP address | +| Endpoint | Rate Limit | Key Type | Purpose | +|----------|------------|----------|---------| +| `/identity/connect/token` | 5 req/min | Email address | Prevent password brute force | +| `/api/accounts/register` | 5 req/min | IP address | Prevent mass registration & email enumeration | +| `/api/accounts/prelogin` | 5 req/min | IP address | Prevent email enumeration | #### How It Works -- The Worker uses a JavaScript wrapper that checks rate limits before forwarding requests to the Rust backend -- For login attempts, the rate limit key is based on the **email address** (not IP), which prevents attackers from bypassing limits by rotating IPs -- When a rate limit is exceeded, a `429 Too Many Requests` response is returned with a Bitwarden-compatible error format +- Rate limiting is implemented natively in Rust using the [`workers-rs`](https://github.com/cloudflare/workers-rs) rate limiter binding +- The rate limit key is based on the **email address** (not IP), which prevents attackers from bypassing limits by rotating IPs +- When a rate limit is exceeded, a `429 Too Many Requests` response is returned - Rate limits are applied per [Cloudflare location](https://www.cloudflare.com/network/), meaning limits are local to each edge location +- If the rate limiter binding is not configured, requests will proceed without rate limiting (graceful degradation) #### Customizing Rate Limits diff --git a/src/error.rs b/src/error.rs index 56a819e..64a8bc6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -21,6 +21,9 @@ pub enum AppError { #[error("Unauthorized: {0}")] Unauthorized(String), + #[error("Too many requests: {0}")] + TooManyRequests(String), + #[error("Cryptography error: {0}")] Crypto(String), @@ -45,6 +48,7 @@ impl IntoResponse for AppError { AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), + AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg), AppError::Crypto(msg) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("Crypto error: {}", msg), diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index 34de68f..65a2378 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -1,4 +1,8 @@ -use axum::{extract::State, Json}; +use axum::{ + extract::State, + http::HeaderMap, + Json, +}; use chrono::Utc; use glob_match::glob_match; use serde_json::{json, Value}; @@ -88,11 +92,29 @@ fn ensure_supported_kdf( #[worker::send] pub async fn prelogin( State(env): State>, + headers: HeaderMap, Json(payload): Json, ) -> Result, AppError> { let email = payload["email"] .as_str() .ok_or_else(|| AppError::BadRequest("Missing email".to_string()))?; + + // Check rate limit using IP address as key to prevent email enumeration attacks + if let Ok(rate_limiter) = env.rate_limiter("LOGIN_RATE_LIMITER") { + let ip = headers + .get("cf-connecting-ip") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + let rate_limit_key = format!("prelogin:{}", ip); + if let Ok(outcome) = rate_limiter.limit(rate_limit_key).await { + if !outcome.success { + return Err(AppError::TooManyRequests( + "Too many requests. Please try again later.".to_string(), + )); + } + } + } + let db = db::get_db(&env)?; let stmt = db.prepare("SELECT kdf_type, kdf_iterations, kdf_memory, kdf_parallelism FROM users WHERE email = ?1"); @@ -132,8 +154,25 @@ pub async fn prelogin( #[worker::send] pub async fn register( State(env): State>, + headers: HeaderMap, Json(payload): Json, ) -> Result, AppError> { + // Check rate limit using IP address as key to prevent mass registration and email enumeration + if let Ok(rate_limiter) = env.rate_limiter("LOGIN_RATE_LIMITER") { + let ip = headers + .get("cf-connecting-ip") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + let rate_limit_key = format!("register:{}", ip); + if let Ok(outcome) = rate_limiter.limit(rate_limit_key).await { + if !outcome.success { + return Err(AppError::TooManyRequests( + "Too many requests. Please try again later.".to_string(), + )); + } + } + } + let allowed_emails = env .secret("ALLOWED_EMAILS") .map_err(|_| AppError::Internal)?; diff --git a/src/handlers/identity.rs b/src/handlers/identity.rs index f186bab..132b0c4 100644 --- a/src/handlers/identity.rs +++ b/src/handlers/identity.rs @@ -140,6 +140,19 @@ pub async fn token( .password .ok_or_else(|| AppError::BadRequest("Missing password".to_string()))?; + // Check rate limit using email as key to prevent brute force attacks + // This limits login attempts per email address, not per IP + if let Ok(rate_limiter) = env.rate_limiter("LOGIN_RATE_LIMITER") { + let rate_limit_key = format!("login:{}", username.to_lowercase()); + if let Ok(outcome) = rate_limiter.limit(rate_limit_key).await { + if !outcome.success { + return Err(AppError::TooManyRequests( + "Too many login attempts. Please try again later.".to_string(), + )); + } + } + } + let user_value: Value = db .prepare("SELECT * FROM users WHERE email = ?1") .bind(&[username.to_lowercase().into()])? diff --git a/src/rate-limit-wrapper.js b/src/rate-limit-wrapper.js deleted file mode 100644 index 11960ab..0000000 --- a/src/rate-limit-wrapper.js +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Rate Limiting Wrapper for Warden Worker - * - * This wrapper intercepts requests before they reach the Rust WASM handler, - * applying rate limiting to sensitive endpoints using Cloudflare's Rate Limiting API. - * - * Rate limiting is applied to: - * - /identity/connect/token (login attempts) - * - /api/accounts/register (registration attempts) - * - /api/accounts/prelogin (prelogin attempts) - * - * The rate limit key is based on: - * - For login: email address (from request body) - * - For other endpoints: IP address (from cf-connecting-ip header) - */ - -import WasmWorker from "../build/index.js"; - -// Endpoints that require rate limiting -const RATE_LIMITED_ENDPOINTS = { - "/identity/connect/token": { - limiter: "LOGIN_RATE_LIMITER", - keyType: "email", // Use email from request body as key - }, - "/api/accounts/register": { - limiter: "LOGIN_RATE_LIMITER", - keyType: "ip", - }, - "/api/accounts/prelogin": { - limiter: "LOGIN_RATE_LIMITER", - keyType: "ip", - }, -}; - -/** - * Extract the rate limit key from the request - * @param {Request} request - The incoming request - * @param {string} keyType - Type of key to extract ('email' or 'ip') - * @returns {Promise} - The rate limit key - */ -async function extractRateLimitKey(request, keyType) { - if (keyType === "email") { - try { - // Clone the request to read the body without consuming it - const clonedRequest = request.clone(); - const contentType = request.headers.get("content-type") || ""; - - if (contentType.includes("application/x-www-form-urlencoded")) { - const formData = await clonedRequest.formData(); - const username = formData.get("username"); - if (username) { - return `email:${username.toLowerCase()}`; - } - } else if (contentType.includes("application/json")) { - const body = await clonedRequest.json(); - const email = body.email || body.username; - if (email) { - return `email:${email.toLowerCase()}`; - } - } - } catch (e) { - console.warn("Failed to extract email from request body:", e); - } - } - - // Fall back to IP address - const ip = request.headers.get("cf-connecting-ip") || "unknown"; - return `ip:${ip}`; -} - -/** - * Check if the request should be rate limited - * @param {Request} request - The incoming request - * @param {Object} env - The environment bindings - * @returns {Promise<{limited: boolean, key: string, endpoint: string} | null>} - */ -async function checkRateLimit(request, env) { - const url = new URL(request.url); - const pathname = url.pathname; - - // Find matching endpoint - const endpointConfig = RATE_LIMITED_ENDPOINTS[pathname]; - if (!endpointConfig) { - return null; // Not a rate-limited endpoint - } - - // Check if the rate limiter binding exists - const limiter = env[endpointConfig.limiter]; - if (!limiter) { - console.warn( - `Rate limiter binding '${endpointConfig.limiter}' not found, skipping rate limit check` - ); - return null; - } - - // Extract the rate limit key - const key = await extractRateLimitKey(request, endpointConfig.keyType); - - // Check rate limit - try { - const { success } = await limiter.limit({ key }); - return { - limited: !success, - key, - endpoint: pathname, - }; - } catch (e) { - console.error("Rate limit check failed:", e); - return null; // On error, allow the request through - } -} - -/** - * Create a 429 Too Many Requests response - * @param {string} key - The rate limit key that was exceeded - * @param {string} endpoint - The endpoint that was rate limited - * @returns {Response} - */ -function createRateLimitResponse(key, endpoint) { - // Bitwarden-compatible error response - const errorResponse = { - error: "too_many_requests", - error_description: "Too many requests. Please try again later.", - ErrorModel: { - Message: "Too many requests. Please try again later.", - Object: "error", - }, - }; - - console.warn(`Rate limit exceeded for ${endpoint}, key: ${key}`); - - return new Response(JSON.stringify(errorResponse), { - status: 429, - headers: { - "Content-Type": "application/json", - "Retry-After": "60", - }, - }); -} - -// Create a single instance of the WASM worker to reuse -let wasmWorkerInstance = null; - -function getWasmWorker(env, ctx) { - // Create a new instance with the current env and ctx - // WorkerEntrypoint classes expect env and ctx to be set - const instance = new WasmWorker(ctx, env); - return instance; -} - -export default { - async fetch(request, env, ctx) { - // Check rate limit for sensitive endpoints - const rateLimitResult = await checkRateLimit(request, env); - - if (rateLimitResult?.limited) { - return createRateLimitResponse( - rateLimitResult.key, - rateLimitResult.endpoint - ); - } - - // Create WASM worker instance and forward the request - const wasmWorker = getWasmWorker(env, ctx); - return wasmWorker.fetch(request); - }, - - async scheduled(event, env, ctx) { - // Create WASM worker instance and forward the scheduled event - const wasmWorker = getWasmWorker(env, ctx); - return wasmWorker.scheduled(event); - }, -}; diff --git a/wrangler.toml b/wrangler.toml index 785dbfd..c39c1c2 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,5 +1,5 @@ name = "warden-worker" -main = "src/rate-limit-wrapper.js" +main = "build/index.js" compatibility_date = "2025-09-19" keep_vars = true