Files
nodewarden/src/services/ratelimit.ts
T

198 lines
6.8 KiB
TypeScript

import { LIMITS } from '../config/limits';
// Rate limiting service.
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
const CONFIG = {
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
};
export class RateLimitService {
private static loginIpTableReady = false;
private static lastLoginIpCleanupAt = 0;
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_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
constructor(private db: D1Database) {}
private shouldRunCleanup(lastRunAt: number, intervalMs: number): boolean {
const now = Date.now();
if (now - lastRunAt < intervalMs) return false;
return Math.random() < RateLimitService.PERIODIC_CLEANUP_PROBABILITY;
}
private async maybeCleanupLoginAttemptsIp(nowMs: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastLoginIpCleanupAt, RateLimitService.LOGIN_IP_CLEANUP_INTERVAL_MS)) {
return;
}
const cutoff = nowMs - RateLimitService.LOGIN_IP_RETENTION_MS;
await this.db
.prepare(
'DELETE FROM login_attempts_ip WHERE updated_at < ? AND (locked_until IS NULL OR locked_until < ?)'
)
.bind(cutoff, nowMs)
.run();
RateLimitService.lastLoginIpCleanupAt = nowMs;
}
private async ensureLoginIpTable(): Promise<void> {
if (RateLimitService.loginIpTableReady) return;
await this.db
.prepare(
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, ' +
'attempts INTEGER NOT NULL, ' +
'locked_until INTEGER, ' +
'updated_at INTEGER NOT NULL' +
')'
)
.run();
RateLimitService.loginIpTableReady = true;
}
async checkLoginAttempt(ip: string): Promise<{
allowed: boolean;
remainingAttempts: number;
retryAfterSeconds?: number;
}> {
await this.ensureLoginIpTable();
const key = ip.trim() || 'unknown';
const now = Date.now();
await this.maybeCleanupLoginAttemptsIp(now);
const row = await this.db
.prepare('SELECT attempts, locked_until FROM login_attempts_ip WHERE ip = ?')
.bind(key)
.first<{ attempts: number; locked_until: number | null }>();
if (!row) {
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
}
if (row.locked_until && row.locked_until > now) {
return {
allowed: false,
remainingAttempts: 0,
retryAfterSeconds: Math.ceil((row.locked_until - now) / 1000),
};
}
if (row.locked_until && row.locked_until <= now) {
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
}
const remainingAttempts = Math.max(0, CONFIG.LOGIN_MAX_ATTEMPTS - (row.attempts || 0));
return { allowed: true, remainingAttempts };
}
async recordFailedLogin(ip: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> {
await this.ensureLoginIpTable();
const key = ip.trim() || 'unknown';
const now = Date.now();
await this.maybeCleanupLoginAttemptsIp(now);
// D1 in Workers forbids raw BEGIN/COMMIT statements.
// Use a single atomic UPSERT to increment attempts.
// This is concurrency-safe because the row is keyed by IP.
await this.db
.prepare(
'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' +
'ON CONFLICT(ip) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at'
)
.bind(key, now)
.run();
const row = await this.db
.prepare('SELECT attempts FROM login_attempts_ip WHERE ip = ?')
.bind(key)
.first<{ attempts: number }>();
const attempts = row?.attempts || 1;
if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
await this.db
.prepare('UPDATE login_attempts_ip SET locked_until = ?, updated_at = ? WHERE ip = ?')
.bind(lockedUntil, now, key)
.run();
return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 };
}
return { locked: false };
}
async clearLoginAttempts(ip: string): Promise<void> {
await this.ensureLoginIpTable();
const key = ip.trim() || 'unknown';
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
}
// Cache API-backed fixed-window rate limiter.
// 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(
identifier: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
const nowSec = Math.floor(Date.now() / 1000);
const windowStart = nowSec - (nowSec % windowSeconds);
const windowEnd = windowStart + windowSeconds;
const ttl = Math.max(1, windowEnd - nowSec);
const cache = await caches.open('rate-limit');
const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`);
const cached = await cache.match(cacheKey);
let count = 0;
if (cached) {
count = parseInt(await cached.text(), 10) || 0;
}
if (count >= maxRequests) {
return { allowed: false, remaining: 0, retryAfterSeconds: ttl };
}
count++;
await cache.put(
cacheKey,
new Response(String(count), {
headers: { 'Cache-Control': `public, max-age=${ttl}` },
})
);
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
}
// General-purpose fixed-window budget.
// Callers supply an identifier (must be unique per rate-limit category) and the
// per-window maximum. This single method replaces all previous specialised
// budget helpers (write / sync / knownDevice / publicSend).
async consumeBudget(
identifier: string,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
}
}
export function getClientIdentifier(request: Request): string {
const cfIp = request.headers.get('CF-Connecting-IP');
if (cfIp) return cfIp;
const forwardedFor = request.headers.get('X-Forwarded-For');
if (forwardedFor) return forwardedFor.split(',')[0].trim();
return 'unknown';
}