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.
This commit is contained in:
parent
eef8ab5acc
commit
8772cd4868
3 changed files with 226 additions and 3 deletions
37
README.md
37
README.md
|
|
@ -218,9 +218,42 @@ If you want to use a custom domain instead of the default `*.workers.dev` domain
|
||||||
- **Worker:** `warden-worker`
|
- **Worker:** `warden-worker`
|
||||||
5. Click **Add route**
|
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:**
|
1. **Navigate to Security Settings:**
|
||||||
- In Cloudflare Dashboard, select your domain (e.g., `example.com`)
|
- In Cloudflare Dashboard, select your domain (e.g., `example.com`)
|
||||||
|
|
|
||||||
173
src/rate-limit-wrapper.js
Normal file
173
src/rate-limit-wrapper.js
Normal file
|
|
@ -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<string>} - 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
name = "warden-worker"
|
name = "warden-worker"
|
||||||
main = "build/index.js"
|
main = "src/rate-limit-wrapper.js"
|
||||||
compatibility_date = "2025-09-19"
|
compatibility_date = "2025-09-19"
|
||||||
keep_vars = true
|
keep_vars = true
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
command = "cargo install -q worker-build && worker-build --release"
|
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
|
# Static assets configuration for serving frontend
|
||||||
# Frontend files should be placed in ./public directory before deployment
|
# Frontend files should be placed in ./public directory before deployment
|
||||||
[assets]
|
[assets]
|
||||||
|
|
@ -43,6 +54,12 @@ keep_vars = true
|
||||||
[env.dev.triggers]
|
[env.dev.triggers]
|
||||||
crons = ["0 3 * * *"]
|
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]]
|
[[env.dev.d1_databases]]
|
||||||
binding = "vault1"
|
binding = "vault1"
|
||||||
database_name = "vault1-dev"
|
database_name = "vault1-dev"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue