From 78d4b301a6a1162dd21c59a1e061fef7a4c53cc3 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 20 Apr 2026 11:46:05 -0700 Subject: [PATCH] worker: stop trusting client-supplied headers for WS client IP The WS message handler read X-Forwarded-For[0] (the leftmost entry, which is client-controllable) to key a per-IP rate limiter and to populate the IP passed to Turnstile, Client, and the unique-IP winner check. A client could send a different XFF per connection to bypass all of these and indefinitely grow the limiter map. - Drop the per-IP limiter entirely; Cloudflare already rate-limits at the edge and no other path used this limiter. - Add getClientIp(): prefer cf-connecting-ip (set by Cloudflare), fall back to req.socket.remoteAddress (always nginx since workers bind 127.0.0.1). Any XFF/X-Real-IP fallback would just give CF's edge IP or a spoofable value, so they're omitted. --- src/server/Worker.ts | 32 +++++--------------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index fd1578332..3d0e58c42 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -3,7 +3,6 @@ import express, { NextFunction, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import http from "http"; import ipAnonymize from "ip-anonymize"; -import { RateLimiter } from "limiter"; import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; @@ -301,16 +300,7 @@ export async function startWorker() { // WebSocket handling wss.on("connection", (ws: WebSocket, req) => { ws.on("message", async (message: string) => { - const forwarded = req.headers["x-forwarded-for"]; - const ip = Array.isArray(forwarded) - ? forwarded[0] - : // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - forwarded || req.socket.remoteAddress || "unknown"; - - if (!getWsIpLimiter(ip).tryRemoveTokens(1)) { - ws.close(1008, "Rate limit exceeded"); - return; - } + const ip = getClientIp(req); try { // Parse and handle client messages @@ -640,20 +630,8 @@ function generateGameIdForWorker(): GameID | null { return null; } -// Per-IP rate limiter for pre-join WebSocket messages. -// Prevents unauthenticated connections from spamming messages -// (e.g. pings) before joining a game. -const wsIpLimiters = new Map(); -function getWsIpLimiter(ip: string): RateLimiter { - let limiter = wsIpLimiters.get(ip); - if (!limiter) { - limiter = new RateLimiter({ - tokensPerInterval: 5, - interval: "second", - }); - wsIpLimiters.set(ip, limiter); - } - return limiter; +function getClientIp(req: http.IncomingMessage): string { + const cfIp = req.headers["cf-connecting-ip"]; + if (typeof cfIp === "string" && cfIp) return cfIp; + return req.socket.remoteAddress ?? "unknown"; } -// Clean up stale IP limiters every 10 minutes -setInterval(() => wsIpLimiters.clear(), 10 * 60 * 1000);