From db370ddd5fa46049de47ef68b63cb7a7c16ab712 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 7 Jul 2026 12:22:50 +0800 Subject: [PATCH 1/3] fix: resolve unauthenticated TURN credential quota abuse Rate-limit /turn-credentials per client IP, reject disallowed browser Origins, and tighten ALLOWED_ORIGINS to official phase.rs hosts plus local dev. Co-authored-by: Cursor --- lobby-worker/src/turn-rate-limit.ts | 46 +++++++++++++++ lobby-worker/src/turn.ts | 69 ++++++++++++++++++++++ lobby-worker/test/turn-rate-limit.test.mjs | 32 ++++++++++ lobby-worker/wrangler.toml | 9 +-- 4 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 lobby-worker/src/turn-rate-limit.ts create mode 100644 lobby-worker/test/turn-rate-limit.test.mjs diff --git a/lobby-worker/src/turn-rate-limit.ts b/lobby-worker/src/turn-rate-limit.ts new file mode 100644 index 0000000000..f81ad6f7ba --- /dev/null +++ b/lobby-worker/src/turn-rate-limit.ts @@ -0,0 +1,46 @@ +export interface RateLimitConfig { + maxRequests: number; + windowMs: number; +} + +interface Bucket { + count: number; + resetAt: number; +} + +/** 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 } { + 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..7a459a3074 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,56 @@ function clientContext(request: Request): { return { colo, country, asn, customIdentifier: `${country}-AS${asn}` }; } +function clientIp(request: Request): string { + return ( + request.headers.get("CF-Connecting-IP") ?? + request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ?? + "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 +128,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..a069fb32b2 --- /dev/null +++ b/lobby-worker/test/turn-rate-limit.test.mjs @@ -0,0 +1,32 @@ +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 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 From f3b0b7aa7bb8ae6067592b3508a038956ba66950 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 7 Jul 2026 21:36:54 +0800 Subject: [PATCH 2/3] fix(lobby-worker): cap TURN rate-limiter bucket map growth Co-authored-by: Cursor --- lobby-worker/src/turn-rate-limit.ts | 14 ++++++++++++++ lobby-worker/test/turn-rate-limit.test.mjs | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/lobby-worker/src/turn-rate-limit.ts b/lobby-worker/src/turn-rate-limit.ts index f81ad6f7ba..b8717f977a 100644 --- a/lobby-worker/src/turn-rate-limit.ts +++ b/lobby-worker/src/turn-rate-limit.ts @@ -8,6 +8,19 @@ interface Bucket { 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(); @@ -18,6 +31,7 @@ export function createRateLimiter(config: RateLimitConfig) { 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 }); diff --git a/lobby-worker/test/turn-rate-limit.test.mjs b/lobby-worker/test/turn-rate-limit.test.mjs index a069fb32b2..2541a5227a 100644 --- a/lobby-worker/test/turn-rate-limit.test.mjs +++ b/lobby-worker/test/turn-rate-limit.test.mjs @@ -23,6 +23,15 @@ test("createRateLimiter allows up to maxRequests per window", () => { 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"; From cd43e34aebfa3f794d40bdbbf1fe961cb88beebf Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Wed, 8 Jul 2026 03:54:39 +0800 Subject: [PATCH 3/3] fix(lobby-worker): rate-limit only on CF-Connecting-IP Co-authored-by: Cursor --- lobby-worker/src/turn.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lobby-worker/src/turn.ts b/lobby-worker/src/turn.ts index 7a459a3074..48a5fe9c43 100644 --- a/lobby-worker/src/turn.ts +++ b/lobby-worker/src/turn.ts @@ -69,11 +69,9 @@ function clientContext(request: Request): { } function clientIp(request: Request): string { - return ( - request.headers.get("CF-Connecting-IP") ?? - request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ?? - "unknown" - ); + // 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"; } /**