feat: enhance rate limiting with new public request budgets and client IP validation

This commit is contained in:
shuaiplus
2026-03-05 02:26:05 +08:00
parent 9db92d13ab
commit 55c5573544
6 changed files with 345 additions and 36 deletions
+39 -13
View File
@@ -215,9 +215,23 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
const method = request.method;
const clientId = getClientIdentifier(request);
async function enforcePublicRateLimit(): Promise<Response | null> {
async function enforcePublicRateLimit(
category: string = 'public',
maxRequests: number = LIMITS.rateLimit.publicRequestsPerMinute
): Promise<Response | null> {
if (!clientId) {
return new Response(JSON.stringify({
error: 'Forbidden',
error_description: 'Client IP is required',
}), {
status: 403,
headers: {
'Content-Type': 'application/json',
},
});
}
const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumeBudget(`${clientId}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
if (check.allowed) return null;
return new Response(JSON.stringify({
error: 'Too many requests',
@@ -254,11 +268,15 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Setup status
if (path === '/setup/status' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleSetupStatus(request, env);
}
// Web runtime config for static client bootstrap
if (path === '/api/web/config' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
const jwtUnsafeReason = jwtSecretUnsafeReason(env);
return jsonResponse({
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
@@ -338,30 +356,27 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleDownloadSendFile(request, env, sendId, fileId);
}
// Notifications hub (stub - no auth required, return 200 for connection)
if (path.startsWith('/notifications/')) {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return new Response(null, { status: 200 });
// Identity endpoints (no auth required)
if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env);
}
// Known device check (no auth required)
// Known device check (no auth required).
if (path === '/api/devices/knowndevice' && method === 'GET') {
const blocked = await enforcePublicRateLimit();
if (blocked) return jsonResponse(false);
return handleKnownDevice(request, env);
}
// Identity endpoints (no auth required)
if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env);
}
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleRevocation(request, env);
}
if (path === '/identity/accounts/prelogin' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handlePrelogin(request, env);
}
@@ -374,6 +389,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// They also tolerate different casing, but their response models use PascalCase.
const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET';
if (isConfigRequest) {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
const origin = url.origin;
return jsonResponse({
// ── Version Strategy (Plan E) ──────────────────────────────────────
@@ -414,6 +431,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Version endpoint (some clients probe this to validate the server)
if (path === '/api/version' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
}
@@ -421,6 +440,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// - first user can self-register and becomes admin
// - later registrations require inviteCode in request body
if (path === '/api/accounts/register' && method === 'POST') {
const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute);
if (blocked) return blocked;
if (!isSameOriginWriteRequest(request)) {
return errorResponse('Forbidden origin', 403);
}
@@ -525,6 +546,11 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleSync(request, env, userId);
}
// Notifications hub (stub): now requires authentication.
if (path.startsWith('/notifications/')) {
return new Response(null, { status: 200 });
}
// Cipher endpoints
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
if (method === 'GET') return handleGetCiphers(request, env, userId);