Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions lobby-worker/src/turn-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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<string, Bucket>, 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<string, Bucket>();

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) {
Comment thread
matthewevans marked this conversation as resolved.
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)));
}
67 changes: 67 additions & 0 deletions lobby-worker/src/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, string> {
Expand Down Expand Up @@ -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<string, string>,
): 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<string, string>,
): 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,
Expand All @@ -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).
Expand Down
41 changes: 41 additions & 0 deletions lobby-worker/test/turn-rate-limit.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
});
9 changes: 5 additions & 4 deletions lobby-worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +72 to +76

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My main question here is that if a missing origin is still allowed, how big of a net benefit is this PR?


# The TURN API token is a SECRET — never put it here. Set it with:
# npx wrangler secret put TURN_KEY_API_TOKEN
Expand Down
Loading