refactor: replace js wrapper with rust binding
This commit is contained in:
parent
8772cd4868
commit
a724719f4b
6 changed files with 67 additions and 183 deletions
17
README.md
17
README.md
|
|
@ -224,18 +224,19 @@ This project includes **built-in rate limiting** powered by [Cloudflare's Rate L
|
||||||
|
|
||||||
#### Protected Endpoints
|
#### Protected Endpoints
|
||||||
|
|
||||||
| Endpoint | Rate Limit | Key Type |
|
| Endpoint | Rate Limit | Key Type | Purpose |
|
||||||
|----------|------------|----------|
|
|----------|------------|----------|---------|
|
||||||
| `/identity/connect/token` | 5 req/min | Email address |
|
| `/identity/connect/token` | 5 req/min | Email address | Prevent password brute force |
|
||||||
| `/api/accounts/register` | 5 req/min | IP address |
|
| `/api/accounts/register` | 5 req/min | IP address | Prevent mass registration & email enumeration |
|
||||||
| `/api/accounts/prelogin` | 5 req/min | IP address |
|
| `/api/accounts/prelogin` | 5 req/min | IP address | Prevent email enumeration |
|
||||||
|
|
||||||
#### How It Works
|
#### How It Works
|
||||||
|
|
||||||
- The Worker uses a JavaScript wrapper that checks rate limits before forwarding requests to the Rust backend
|
- Rate limiting is implemented natively in Rust using the [`workers-rs`](https://github.com/cloudflare/workers-rs) rate limiter binding
|
||||||
- For login attempts, the rate limit key is based on the **email address** (not IP), which prevents attackers from bypassing limits by rotating IPs
|
- 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
|
- 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
|
- 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
|
#### Customizing Rate Limits
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ pub enum AppError {
|
||||||
#[error("Unauthorized: {0}")]
|
#[error("Unauthorized: {0}")]
|
||||||
Unauthorized(String),
|
Unauthorized(String),
|
||||||
|
|
||||||
|
#[error("Too many requests: {0}")]
|
||||||
|
TooManyRequests(String),
|
||||||
|
|
||||||
#[error("Cryptography error: {0}")]
|
#[error("Cryptography error: {0}")]
|
||||||
Crypto(String),
|
Crypto(String),
|
||||||
|
|
||||||
|
|
@ -45,6 +48,7 @@ impl IntoResponse for AppError {
|
||||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||||
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
|
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
|
||||||
|
AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg),
|
||||||
AppError::Crypto(msg) => (
|
AppError::Crypto(msg) => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("Crypto error: {}", msg),
|
format!("Crypto error: {}", msg),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
use axum::{extract::State, Json};
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::HeaderMap,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use glob_match::glob_match;
|
use glob_match::glob_match;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
@ -88,11 +92,29 @@ fn ensure_supported_kdf(
|
||||||
#[worker::send]
|
#[worker::send]
|
||||||
pub async fn prelogin(
|
pub async fn prelogin(
|
||||||
State(env): State<Arc<Env>>,
|
State(env): State<Arc<Env>>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<Json<PreloginResponse>, AppError> {
|
) -> Result<Json<PreloginResponse>, AppError> {
|
||||||
let email = payload["email"]
|
let email = payload["email"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| AppError::BadRequest("Missing email".to_string()))?;
|
.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 db = db::get_db(&env)?;
|
||||||
|
|
||||||
let stmt = db.prepare("SELECT kdf_type, kdf_iterations, kdf_memory, kdf_parallelism FROM users WHERE email = ?1");
|
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]
|
#[worker::send]
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(env): State<Arc<Env>>,
|
State(env): State<Arc<Env>>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(payload): Json<RegisterRequest>,
|
Json(payload): Json<RegisterRequest>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, 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
|
let allowed_emails = env
|
||||||
.secret("ALLOWED_EMAILS")
|
.secret("ALLOWED_EMAILS")
|
||||||
.map_err(|_| AppError::Internal)?;
|
.map_err(|_| AppError::Internal)?;
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,19 @@ pub async fn token(
|
||||||
.password
|
.password
|
||||||
.ok_or_else(|| AppError::BadRequest("Missing password".to_string()))?;
|
.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
|
let user_value: Value = db
|
||||||
.prepare("SELECT * FROM users WHERE email = ?1")
|
.prepare("SELECT * FROM users WHERE email = ?1")
|
||||||
.bind(&[username.to_lowercase().into()])?
|
.bind(&[username.to_lowercase().into()])?
|
||||||
|
|
|
||||||
|
|
@ -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<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,5 +1,5 @@
|
||||||
name = "warden-worker"
|
name = "warden-worker"
|
||||||
main = "src/rate-limit-wrapper.js"
|
main = "build/index.js"
|
||||||
compatibility_date = "2025-09-19"
|
compatibility_date = "2025-09-19"
|
||||||
keep_vars = true
|
keep_vars = true
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue