diff --git a/lobby-worker/src/turn-rate-limit.ts b/lobby-worker/src/turn-rate-limit.ts new file mode 100644 index 0000000000..b8717f977a --- /dev/null +++ b/lobby-worker/src/turn-rate-limit.ts @@ -0,0 +1,60 @@ +export interface RateLimitConfig { + maxRequests: number; + windowMs: number; +} + +interface Bucket { + count: number; + resetAt: number; +} + +const MAX_BUCKETS = 10_000; + +function pruneBuckets(buckets: Map, now: number): void { + for (const [key, entry] of buckets) { + if (now >= entry.resetAt) buckets.delete(key); + } + if (buckets.size <= MAX_BUCKETS) return; + const victims = [...buckets.entries()] + .sort((a, b) => a[1].resetAt - b[1].resetAt) + .slice(0, buckets.size - MAX_BUCKETS); + for (const [key] of victims) buckets.delete(key); +} + +/** In-memory per-key sliding window limiter (per isolate). */ +export function createRateLimiter(config: RateLimitConfig) { + const buckets = new Map(); + + return { + check( + key: string, + now = Date.now(), + maxRequests = config.maxRequests, + ): { allowed: true } | { allowed: false; retryAfterSeconds: number } { + pruneBuckets(buckets, now); + const entry = buckets.get(key); + if (!entry || now >= entry.resetAt) { + buckets.set(key, { count: 1, resetAt: now + config.windowMs }); + return { allowed: true }; + } + if (entry.count >= maxRequests) { + return { + allowed: false, + retryAfterSeconds: Math.max(1, Math.ceil((entry.resetAt - now) / 1000)), + }; + } + entry.count += 1; + return { allowed: true }; + }, + + reset(): void { + buckets.clear(); + }, + }; +} + +export function parseTurnRateLimitPerHour(raw: string | undefined): number { + const parsed = Number(raw ?? "30"); + if (!Number.isFinite(parsed)) return 30; + return Math.min(120, Math.max(1, Math.floor(parsed))); +} diff --git a/lobby-worker/src/turn.ts b/lobby-worker/src/turn.ts index 5418d432d3..48a5fe9c43 100644 --- a/lobby-worker/src/turn.ts +++ b/lobby-worker/src/turn.ts @@ -6,6 +6,17 @@ // API with the secret token and forwards the resulting ICE servers. // Docs: https://developers.cloudflare.com/realtime/turn/generate-credentials/ +import { + createRateLimiter, + parseTurnRateLimitPerHour, +} from "./turn-rate-limit"; + +const TURN_RATE_WINDOW_MS = 60 * 60 * 1000; +const turnRateLimiter = createRateLimiter({ + maxRequests: 30, + windowMs: TURN_RATE_WINDOW_MS, +}); + export interface TurnEnv { /** Cloudflare Realtime TURN key ID (var, not secret). */ TURN_KEY_ID?: string; @@ -15,6 +26,8 @@ export interface TurnEnv { TURN_TTL_SECONDS?: string; /** Comma-separated origin allowlist, or "*" (default) to allow any. */ ALLOWED_ORIGINS?: string; + /** Max credential mints per client IP per hour (default 30, max 120). */ + TURN_RATE_LIMIT_PER_HOUR?: string; } function corsHeaders(request: Request, env: TurnEnv): Record { @@ -55,6 +68,54 @@ function clientContext(request: Request): { return { colo, country, asn, customIdentifier: `${country}-AS${asn}` }; } +function clientIp(request: Request): string { + // Rate-limit keys must come from Cloudflare edge metadata only; X-Forwarded-For + // is client-controlled and would let callers rotate spoofed IPs to bypass limits. + return request.headers.get("CF-Connecting-IP") ?? "unknown"; +} + +/** + * When `ALLOWED_ORIGINS` is tightened, reject browser requests whose `Origin` + * header is present but not on the allowlist. Missing `Origin` is allowed so + * Tauri/desktop fetches (no Origin) keep working. + */ +function rejectDisallowedOrigin( + request: Request, + env: TurnEnv, + cors: Record, +): Response | null { + const allow = (env.ALLOWED_ORIGINS ?? "*").trim(); + if (allow === "*") return null; + const origin = request.headers.get("Origin"); + if (!origin) return null; + const list = allow.split(",").map((s) => s.trim()).filter(Boolean); + if (list.includes(origin)) return null; + return Response.json( + { error: "Origin not allowed" }, + { status: 403, headers: cors }, + ); +} + +function rejectRateLimited( + request: Request, + env: TurnEnv, + cors: Record, +): Response | null { + const maxRequests = parseTurnRateLimitPerHour(env.TURN_RATE_LIMIT_PER_HOUR); + const result = turnRateLimiter.check(clientIp(request), Date.now(), maxRequests); + if (result.allowed) return null; + return Response.json( + { error: "TURN credential rate limit exceeded" }, + { + status: 429, + headers: { + ...cors, + "Retry-After": String(result.retryAfterSeconds), + }, + }, + ); +} + export async function handleTurnCredentials( request: Request, env: TurnEnv, @@ -65,6 +126,12 @@ export async function handleTurnCredentials( return new Response(null, { status: 204, headers: cors }); } + const originBlock = rejectDisallowedOrigin(request, env, cors); + if (originBlock) return originBlock; + + const rateBlock = rejectRateLimited(request, env, cors); + if (rateBlock) return rateBlock; + if (!env.TURN_KEY_ID || !env.TURN_KEY_API_TOKEN) { // Not configured yet — the client falls back to STUN-only (direct/STUN // connections still work; symmetric-NAT peers won't relay until this is set). diff --git a/lobby-worker/test/turn-rate-limit.test.mjs b/lobby-worker/test/turn-rate-limit.test.mjs new file mode 100644 index 0000000000..2541a5227a --- /dev/null +++ b/lobby-worker/test/turn-rate-limit.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + createRateLimiter, + parseTurnRateLimitPerHour, +} from "../src/turn-rate-limit.ts"; + +test("parseTurnRateLimitPerHour clamps to [1, 120]", () => { + assert.equal(parseTurnRateLimitPerHour(undefined), 30); + assert.equal(parseTurnRateLimitPerHour("0"), 1); + assert.equal(parseTurnRateLimitPerHour("999"), 120); + assert.equal(parseTurnRateLimitPerHour("not-a-number"), 30); +}); + +test("createRateLimiter allows up to maxRequests per window", () => { + const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 }); + const key = "1.2.3.4"; + assert.deepEqual(limiter.check(key, 1_000), { allowed: true }); + assert.deepEqual(limiter.check(key, 2_000), { allowed: true }); + const blocked = limiter.check(key, 3_000); + assert.equal(blocked.allowed, false); + assert.equal(blocked.retryAfterSeconds, 58); +}); + +test("createRateLimiter prunes expired buckets and caps map growth", () => { + const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 }); + for (let i = 0; i < 10_050; i++) { + limiter.check(`key-${i}`, 0); + } + // After window elapses, stale keys are pruned before inserting a new one. + assert.deepEqual(limiter.check("fresh-key", 61_000), { allowed: true }); +}); + +test("createRateLimiter resets after the window elapses", () => { + const limiter = createRateLimiter({ maxRequests: 1, windowMs: 1_000 }); + const key = "client"; + assert.deepEqual(limiter.check(key, 0), { allowed: true }); + assert.equal(limiter.check(key, 100).allowed, false); + assert.deepEqual(limiter.check(key, 1_001), { allowed: true }); +}); diff --git a/lobby-worker/wrangler.toml b/lobby-worker/wrangler.toml index f49cbca33f..d0bf58ff78 100644 --- a/lobby-worker/wrangler.toml +++ b/lobby-worker/wrangler.toml @@ -69,10 +69,11 @@ invocation_logs = false TURN_KEY_ID = "97c7984d6c155084f9d0fdf8090e02f8" # Credential lifetime in seconds (Cloudflare max 48h). 24h covers any session. TURN_TTL_SECONDS = "86400" -# Origin allowlist for the /turn-credentials fetch. "*" is fine during rollout -# (TURN creds are short-lived; worst case is quota use). Tighten for prod, e.g. -# "https://phase-rs.dev,http://localhost:5173". -ALLOWED_ORIGINS = "*" +# Max TURN credential mints per client IP per hour (abuse guard). +TURN_RATE_LIMIT_PER_HOUR = "30" +# Origin allowlist for the /turn-credentials fetch. Missing Origin is still +# allowed (Tauri/desktop). Browsers with a present but disallowed Origin get 403. +ALLOWED_ORIGINS = "https://phase-rs.dev,https://preview.phase-rs.dev,http://localhost:5173,http://127.0.0.1:5173" # The TURN API token is a SECRET — never put it here. Set it with: # npx wrangler secret put TURN_KEY_API_TOKEN