Found during the cross-model review of #20 (both Codex and Claude, signup-security). Pre-existing / inherited — not introduced by #20.
getClientIp() returns the leftmost X-Forwarded-For value (web/lib/rateLimit.ts ~L211-216), used as the identifier in withRateLimit({ identifier: 'ip' }). Behind Railway/Cloudflare the real client IP is appended, so index 0 is attacker-controlled — a caller can rotate XFF per request to get fresh signup buckets and defeat the 10/hr cap.
Mitigated in practice by the verified-Firebase-token requirement on bootstrap, so this only relaxes the per-IP cap, not auth.
Fix: derive the client IP from a provider-owned header (CF-Connecting-IP) or parse XFF right-to-left skipping trusted proxy hops; ignore client-supplied XFF.
Found during the cross-model review of #20 (both Codex and Claude, signup-security). Pre-existing / inherited — not introduced by #20.
getClientIp()returns the leftmostX-Forwarded-Forvalue (web/lib/rateLimit.ts ~L211-216), used as the identifier inwithRateLimit({ identifier: 'ip' }). Behind Railway/Cloudflare the real client IP is appended, so index 0 is attacker-controlled — a caller can rotate XFF per request to get fresh signup buckets and defeat the 10/hr cap.Mitigated in practice by the verified-Firebase-token requirement on bootstrap, so this only relaxes the per-IP cap, not auth.
Fix: derive the client IP from a provider-owned header (
CF-Connecting-IP) or parse XFF right-to-left skipping trusted proxy hops; ignore client-supplied XFF.