From 8772cd48683b3107d691a4cf8d997af4e1e9e8b8 Mon Sep 17 00:00:00 2001 From: qaz741wsd856 Date: Thu, 4 Dec 2025 17:21:15 +0000 Subject: [PATCH] feat: apply worker built-in rate limiting for sensitive endpoints - Added a rate limiting wrapper to intercept requests and apply limits to login, registration, and prelogin endpoints. - Configured rate limits in `wrangler.toml` for both production and development environments. - Updated README to document the rate limiting feature, including how it works and customization options. --- README.md | 37 +++++++- src/rate-limit-wrapper.js | 173 ++++++++++++++++++++++++++++++++++++++ wrangler.toml | 19 ++++- 3 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 src/rate-limit-wrapper.js diff --git a/README.md b/README.md index 827aa78..5cf4e31 100644 --- a/README.md +++ b/README.md @@ -218,9 +218,42 @@ If you want to use a custom domain instead of the default `*.workers.dev` domain - **Worker:** `warden-worker` 5. Click **Add route** -### Configure Rate Limiting (Recommended) +### Built-in Rate Limiting -To protect your authentication endpoints from brute force attacks, it's highly recommended to configure rate limiting rules: +This project includes **built-in rate limiting** powered by [Cloudflare's Rate Limiting API](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/). Rate limiting is automatically applied to sensitive endpoints to protect against brute force attacks. + +#### 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 | + +#### 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 limits are applied per [Cloudflare location](https://www.cloudflare.com/network/), meaning limits are local to each edge location + +#### Customizing Rate Limits + +You can adjust the rate limit settings in `wrangler.toml`: + +```toml +[[ratelimits]] +name = "LOGIN_RATE_LIMITER" +namespace_id = "1001" +# Adjust limit (requests) and period (10 or 60 seconds) +simple = { limit = 5, period = 60 } +``` + +> **Note:** The `period` must be either `10` or `60` seconds. See [Cloudflare documentation](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) for details. + +#### Additional Protection (Optional) + +For additional security, you can also configure rate limiting rules at the WAF level: 1. **Navigate to Security Settings:** - In Cloudflare Dashboard, select your domain (e.g., `example.com`) diff --git a/src/rate-limit-wrapper.js b/src/rate-limit-wrapper.js new file mode 100644 index 0000000..11960ab --- /dev/null +++ b/src/rate-limit-wrapper.js @@ -0,0 +1,173 @@ +/** + * 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 b388d03..785dbfd 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,11 +1,22 @@ name = "warden-worker" -main = "build/index.js" +main = "src/rate-limit-wrapper.js" compatibility_date = "2025-09-19" keep_vars = true [build] command = "cargo install -q worker-build && worker-build --release" +# Rate limiting configuration for protecting sensitive endpoints +# See: https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/ +[[ratelimits]] +name = "LOGIN_RATE_LIMITER" +# A unique identifier for this rate limiter within your Cloudflare account +# You can use any positive integer +namespace_id = "1001" +# Allow 5 requests per 60 seconds per key (email or IP) +# This prevents brute force attacks while allowing legitimate login attempts +simple = { limit = 5, period = 60 } + # Static assets configuration for serving frontend # Frontend files should be placed in ./public directory before deployment [assets] @@ -43,6 +54,12 @@ keep_vars = true [env.dev.triggers] crons = ["0 3 * * *"] +# Rate limiting for dev environment (same settings as production) +[[env.dev.ratelimits]] +name = "LOGIN_RATE_LIMITER" +namespace_id = "1002" +simple = { limit = 5, period = 60 } + [[env.dev.d1_databases]] binding = "vault1" database_name = "vault1-dev"