mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: unify API rate limiting and enhance request budgets
This commit is contained in:
+6
-18
@@ -32,18 +32,12 @@
|
|||||||
// Login lock duration in minutes.
|
// Login lock duration in minutes.
|
||||||
// 登录锁定时长(分钟)。
|
// 登录锁定时长(分钟)。
|
||||||
loginLockoutMinutes: 2,
|
loginLockoutMinutes: 2,
|
||||||
// Write API request budget per minute.
|
// Authenticated API request budget per user per minute (all reads & writes combined).
|
||||||
// 写操作 API 每分钟请求配额。
|
// 认证 API 每用户每分钟请求配额(读写合计)。
|
||||||
apiWriteRequestsPerMinute: 120,
|
apiRequestsPerMinute: 200,
|
||||||
// /api/sync read request budget per minute.
|
// Public (unauthenticated) request budget per IP per minute.
|
||||||
// /api/sync 读请求每分钟配额。
|
// 公开(未认证)接口每 IP 每分钟请求配额。
|
||||||
syncReadRequestsPerMinute: 1000,
|
publicRequestsPerMinute: 60,
|
||||||
// /api/devices/knowndevice probe budget per IP per minute.
|
|
||||||
// /api/devices/knowndevice 每 IP 每分钟探测配额。
|
|
||||||
knownDeviceRequestsPerMinute: 10,
|
|
||||||
// Public Send access budget per IP per minute.
|
|
||||||
// 公共 Send 访问接口每 IP 每分钟配额。
|
|
||||||
publicSendRequestsPerMinute: 60,
|
|
||||||
// Fixed window size for API rate limiting in seconds.
|
// Fixed window size for API rate limiting in seconds.
|
||||||
// API 限流固定窗口大小(秒)。
|
// API 限流固定窗口大小(秒)。
|
||||||
apiWindowSeconds: 60,
|
apiWindowSeconds: 60,
|
||||||
@@ -53,15 +47,9 @@
|
|||||||
// Minimum interval between login-attempt cleanup runs.
|
// Minimum interval between login-attempt cleanup runs.
|
||||||
// 登录尝试表清理的最小间隔。
|
// 登录尝试表清理的最小间隔。
|
||||||
loginIpCleanupIntervalMs: 10 * 60 * 1000,
|
loginIpCleanupIntervalMs: 10 * 60 * 1000,
|
||||||
// Minimum interval between API-window cleanup runs.
|
|
||||||
// API 窗口计数清理的最小间隔。
|
|
||||||
apiWindowCleanupIntervalMs: 5 * 60 * 1000,
|
|
||||||
// Retention window for login IP records.
|
// Retention window for login IP records.
|
||||||
// 登录 IP 记录保留时长。
|
// 登录 IP 记录保留时长。
|
||||||
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
|
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
|
||||||
// Number of historical API windows to keep.
|
|
||||||
// 保留的历史 API 窗口数量。
|
|
||||||
apiWindowRetentionWindows: 120,
|
|
||||||
},
|
},
|
||||||
cleanup: {
|
cleanup: {
|
||||||
// Minimum interval between refresh-token cleanup runs.
|
// Minimum interval between refresh-token cleanup runs.
|
||||||
|
|||||||
@@ -267,10 +267,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
return jsonResponse(response);
|
return jsonResponse(response);
|
||||||
|
|
||||||
} else if (grantType === 'send_access') {
|
} else if (grantType === 'send_access') {
|
||||||
const sendAccessLimit = await rateLimit.consumePublicSendAccessBudget(`${clientIdentifier}:public-send-oauth`);
|
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||||
if (!sendAccessLimit.allowed) {
|
if (!sendAccessLimit.allowed) {
|
||||||
return identityErrorResponse(
|
return identityErrorResponse(
|
||||||
`Too many public Send requests. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
|
`Rate limit exceeded. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
|
||||||
'TooManyRequests',
|
'TooManyRequests',
|
||||||
429
|
429
|
||||||
);
|
);
|
||||||
|
|||||||
+15
-37
@@ -215,13 +215,13 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
const method = request.method;
|
const method = request.method;
|
||||||
const clientId = getClientIdentifier(request);
|
const clientId = getClientIdentifier(request);
|
||||||
|
|
||||||
async function enforcePublicSendRateLimit(): Promise<Response | null> {
|
async function enforcePublicRateLimit(): Promise<Response | null> {
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
const check = await rateLimit.consumePublicSendAccessBudget(`${clientId}:public-send`);
|
const check = await rateLimit.consumeBudget(`${clientId}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||||
if (check.allowed) return null;
|
if (check.allowed) return null;
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
error: 'Too many requests',
|
error: 'Too many requests',
|
||||||
error_description: `Too many public Send requests. Try again in ${check.retryAfterSeconds} seconds.`,
|
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
|
||||||
}), {
|
}), {
|
||||||
status: 429,
|
status: 429,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -289,7 +289,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
// Public Send access endpoints
|
// Public Send access endpoints
|
||||||
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
||||||
if (sendAccessMatch && method === 'POST') {
|
if (sendAccessMatch && method === 'POST') {
|
||||||
const blocked = await enforcePublicSendRateLimit();
|
const blocked = await enforcePublicRateLimit();
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const accessId = sendAccessMatch[1];
|
const accessId = sendAccessMatch[1];
|
||||||
return handleAccessSend(request, env, accessId);
|
return handleAccessSend(request, env, accessId);
|
||||||
@@ -297,14 +297,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
|
|
||||||
const sendAccessV2Match = path === '/api/sends/access';
|
const sendAccessV2Match = path === '/api/sends/access';
|
||||||
if (sendAccessV2Match && method === 'POST') {
|
if (sendAccessV2Match && method === 'POST') {
|
||||||
const blocked = await enforcePublicSendRateLimit();
|
const blocked = await enforcePublicRateLimit();
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
return handleAccessSendV2(request, env);
|
return handleAccessSendV2(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([a-f0-9-]+)$/i);
|
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([a-f0-9-]+)$/i);
|
||||||
if (sendAccessFileV2Match && method === 'POST') {
|
if (sendAccessFileV2Match && method === 'POST') {
|
||||||
const blocked = await enforcePublicSendRateLimit();
|
const blocked = await enforcePublicRateLimit();
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const fileId = sendAccessFileV2Match[1];
|
const fileId = sendAccessFileV2Match[1];
|
||||||
return handleAccessSendFileV2(request, env, fileId);
|
return handleAccessSendFileV2(request, env, fileId);
|
||||||
@@ -312,7 +312,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
|
|
||||||
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([a-f0-9-]+)$/i);
|
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([a-f0-9-]+)$/i);
|
||||||
if (sendAccessFileMatch && method === 'POST') {
|
if (sendAccessFileMatch && method === 'POST') {
|
||||||
const blocked = await enforcePublicSendRateLimit();
|
const blocked = await enforcePublicRateLimit();
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const idOrAccessId = sendAccessFileMatch[1];
|
const idOrAccessId = sendAccessFileMatch[1];
|
||||||
const fileId = sendAccessFileMatch[2];
|
const fileId = sendAccessFileMatch[2];
|
||||||
@@ -333,12 +333,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
|
|
||||||
// Known device check (no auth required)
|
// Known device check (no auth required)
|
||||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
const blocked = await enforcePublicRateLimit();
|
||||||
const probeLimit = await rateLimit.consumeKnownDeviceProbeBudget(clientId + ':known-device');
|
if (blocked) return jsonResponse(false);
|
||||||
if (!probeLimit.allowed) {
|
|
||||||
// Keep compatibility simple: do not error, just answer "unknown device".
|
|
||||||
return jsonResponse(false);
|
|
||||||
}
|
|
||||||
return handleKnownDevice(request, env);
|
return handleKnownDevice(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,31 +437,13 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
if (currentUser.status !== 'active') {
|
if (currentUser.status !== 'active') {
|
||||||
return errorResponse('Account is disabled', 403);
|
return errorResponse('Account is disabled', 403);
|
||||||
}
|
}
|
||||||
// Dedicated read rate limiting for heavy sync endpoint.
|
// Unified rate limiting for all authenticated API requests.
|
||||||
if (path === '/api/sync' && method === 'GET') {
|
{
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
const rateLimitCheck = await rateLimit.consumeSyncReadBudget(userId + ':' + clientId + ':sync');
|
const rateLimitCheck = await rateLimit.consumeBudget(
|
||||||
|
userId + ':api',
|
||||||
if (!rateLimitCheck.allowed) {
|
LIMITS.rateLimit.apiRequestsPerMinute
|
||||||
return new Response(JSON.stringify({
|
);
|
||||||
error: 'Too many requests',
|
|
||||||
error_description: `Sync rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
|
||||||
}), {
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
|
|
||||||
'X-RateLimit-Remaining': '0',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// API rate limiting only for write operations (keep reads frictionless)
|
|
||||||
const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH';
|
|
||||||
if (isWriteMethod) {
|
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
|
||||||
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write');
|
|
||||||
|
|
||||||
if (!rateLimitCheck.allowed) {
|
if (!rateLimitCheck.allowed) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
|
|||||||
+32
-92
@@ -1,37 +1,22 @@
|
|||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
// D1-backed rate limiting.
|
// Rate limiting service.
|
||||||
// Notes:
|
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
|
||||||
// - Login attempts are tracked per client IP.
|
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
|
||||||
// - API rate is tracked per identifier per fixed window.
|
|
||||||
|
|
||||||
// Rate limit configuration
|
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
// Friendly default: short cooldown instead of long lockouts.
|
|
||||||
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
||||||
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
|
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
|
||||||
|
|
||||||
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
|
|
||||||
API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
|
|
||||||
// Dedicated budget for GET /api/sync reads.
|
|
||||||
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
|
|
||||||
// Dedicated budget for GET /api/devices/knowndevice probes.
|
|
||||||
KNOWN_DEVICE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.knownDeviceRequestsPerMinute,
|
|
||||||
// Dedicated budget for unauthenticated public Send access endpoints.
|
|
||||||
PUBLIC_SEND_REQUESTS_PER_MINUTE: LIMITS.rateLimit.publicSendRequestsPerMinute,
|
|
||||||
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
|
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RateLimitService {
|
export class RateLimitService {
|
||||||
private static loginIpTableReady = false;
|
private static loginIpTableReady = false;
|
||||||
private static lastLoginIpCleanupAt = 0;
|
private static lastLoginIpCleanupAt = 0;
|
||||||
private static lastApiWindowCleanupAt = 0;
|
|
||||||
|
|
||||||
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
|
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
|
||||||
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
||||||
private static readonly API_WINDOW_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.apiWindowCleanupIntervalMs;
|
|
||||||
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
|
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
|
||||||
private static readonly API_WINDOW_RETENTION_WINDOWS = LIMITS.rateLimit.apiWindowRetentionWindows;
|
|
||||||
|
|
||||||
constructor(private db: D1Database) {}
|
constructor(private db: D1Database) {}
|
||||||
|
|
||||||
@@ -56,16 +41,6 @@ export class RateLimitService {
|
|||||||
RateLimitService.lastLoginIpCleanupAt = nowMs;
|
RateLimitService.lastLoginIpCleanupAt = nowMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async maybeCleanupApiWindows(windowStart: number, windowSeconds: number): Promise<void> {
|
|
||||||
if (!this.shouldRunCleanup(RateLimitService.lastApiWindowCleanupAt, RateLimitService.API_WINDOW_CLEANUP_INTERVAL_MS)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cutoff = windowStart - (windowSeconds * RateLimitService.API_WINDOW_RETENTION_WINDOWS);
|
|
||||||
await this.db.prepare('DELETE FROM api_rate_limits WHERE window_start < ?').bind(cutoff).run();
|
|
||||||
RateLimitService.lastApiWindowCleanupAt = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureLoginIpTable(): Promise<void> {
|
private async ensureLoginIpTable(): Promise<void> {
|
||||||
if (RateLimitService.loginIpTableReady) return;
|
if (RateLimitService.loginIpTableReady) return;
|
||||||
|
|
||||||
@@ -162,8 +137,9 @@ export class RateLimitService {
|
|||||||
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomically consume one budget unit for the current fixed window.
|
// Cache API-backed fixed-window rate limiter.
|
||||||
// Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment.
|
// Uses Cloudflare edge cache instead of D1 — zero database writes, auto-expires via TTL.
|
||||||
|
// Per-colo isolation is acceptable (matches Cloudflare's own rate limiting behaviour).
|
||||||
private async consumeFixedWindowBudget(
|
private async consumeFixedWindowBudget(
|
||||||
identifier: string,
|
identifier: string,
|
||||||
maxRequests: number,
|
maxRequests: number,
|
||||||
@@ -172,77 +148,41 @@ export class RateLimitService {
|
|||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const windowStart = nowSec - (nowSec % windowSeconds);
|
const windowStart = nowSec - (nowSec % windowSeconds);
|
||||||
const windowEnd = windowStart + windowSeconds;
|
const windowEnd = windowStart + windowSeconds;
|
||||||
await this.maybeCleanupApiWindows(windowStart, windowSeconds);
|
const ttl = Math.max(1, windowEnd - nowSec);
|
||||||
|
|
||||||
const writeResult = await this.db
|
const cache = await caches.open('rate-limit');
|
||||||
.prepare(
|
const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`);
|
||||||
'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
|
|
||||||
'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' +
|
|
||||||
'WHERE api_rate_limits.count < ?'
|
|
||||||
)
|
|
||||||
.bind(identifier, windowStart, maxRequests)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// No changed row means conflict happened and WHERE prevented increment:
|
const cached = await cache.match(cacheKey);
|
||||||
// current count is already at/above configured limit.
|
let count = 0;
|
||||||
if ((writeResult.meta.changes ?? 0) === 0) {
|
if (cached) {
|
||||||
return {
|
count = parseInt(await cached.text(), 10) || 0;
|
||||||
allowed: false,
|
|
||||||
remaining: 0,
|
|
||||||
retryAfterSeconds: windowEnd - nowSec,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = await this.db
|
if (count >= maxRequests) {
|
||||||
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?')
|
return { allowed: false, remaining: 0, retryAfterSeconds: ttl };
|
||||||
.bind(identifier, windowStart)
|
|
||||||
.first<{ count: number }>();
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
remaining: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const remaining = Math.max(0, maxRequests - row.count);
|
count++;
|
||||||
return { allowed: true, remaining };
|
await cache.put(
|
||||||
}
|
cacheKey,
|
||||||
|
new Response(String(count), {
|
||||||
// Write budget for POST/PUT/DELETE/PATCH requests.
|
headers: { 'Cache-Control': `public, max-age=${ttl}` },
|
||||||
async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
})
|
||||||
return this.consumeFixedWindowBudget(
|
|
||||||
identifier,
|
|
||||||
CONFIG.API_WRITE_REQUESTS_PER_MINUTE,
|
|
||||||
CONFIG.API_WINDOW_SECONDS
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read budget for GET /api/sync.
|
// General-purpose fixed-window budget.
|
||||||
async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
// Callers supply an identifier (must be unique per rate-limit category) and the
|
||||||
return this.consumeFixedWindowBudget(
|
// per-window maximum. This single method replaces all previous specialised
|
||||||
identifier,
|
// budget helpers (write / sync / knownDevice / publicSend).
|
||||||
CONFIG.SYNC_READ_REQUESTS_PER_MINUTE,
|
async consumeBudget(
|
||||||
CONFIG.API_WINDOW_SECONDS
|
identifier: string,
|
||||||
);
|
maxRequests: number
|
||||||
}
|
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
|
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
|
||||||
// Probe budget for GET /api/devices/knowndevice.
|
|
||||||
async consumeKnownDeviceProbeBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
|
||||||
return this.consumeFixedWindowBudget(
|
|
||||||
identifier,
|
|
||||||
CONFIG.KNOWN_DEVICE_REQUESTS_PER_MINUTE,
|
|
||||||
CONFIG.API_WINDOW_SECONDS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Budget for unauthenticated public Send access endpoints.
|
|
||||||
async consumePublicSendAccessBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
|
||||||
return this.consumeFixedWindowBudget(
|
|
||||||
identifier,
|
|
||||||
CONFIG.PUBLIC_SEND_REQUESTS_PER_MINUTE,
|
|
||||||
CONFIG.API_WINDOW_SECONDS
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user