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.
This commit is contained in:
evanpelle
2026-04-20 11:46:05 -07:00
parent 328b8859d3
commit 78d4b301a6
+5 -27
View File
@@ -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<string, RateLimiter>();
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);