fix: rate-limit visitors by real IP regardless of Cloudflare proxy hops (#750)

Since the night of 2026-07-16 production keyed the rate limiter on
Cloudflare edge IPs instead of visitor IPs: every visitor routed through
the same edge shared one 175-req/15min bucket, causing widespread 429s
(confirmed by probing: sequential requests alternated 200/429 across
fresh and exhausted buckets).

Root cause: 'trust proxy' used a fixed hop count (TRUST_PROXY=1), which
silently breaks whenever the proxy chain gains or loses an
X-Forwarded-For entry (e.g. a Cloudflare-side change).

- TRUST_PROXY now accepts a comma-separated subnet list; the new default
  'loopback,uniquelocal,cloudflare' expands Cloudflare's published IP
  ranges so Express skips trusted proxies no matter how many entries
  they add. Plain numbers keep the legacy hop-count behavior.
- If resolution still stops at a Cloudflare address (visitor missing
  from X-Forwarded-For entirely), the limiter key falls back to
  cf-connecting-ip — safe because request.ip can only be a Cloudflare
  address when the whole chain to it is trusted.
- CIDR matching uses Node's built-in net.BlockList (no new dependency).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Thomas DurieuxandClaude Fable 5 authored and GitHub committed 2026-07-17 06:44:21 +02:00
1 parent 3f2d6d1a64
commit 6a820408a1
5 files changed
+288 -19

No files matched your search

+30 -11
View File
@@ -29,6 +29,7 @@ import {
import DailyStatsModel from "../core/model/dailyStats/dailyStats.model";
import { getUser } from "./routes/route-utils";
import config from "../config";
import { resolveTrustProxy, isCloudflareIP } from "./trustProxy";
import { createLogger, serializeError } from "../core/logger";
const logger = createLogger("server");
@@ -109,10 +110,14 @@ export default async function start() {
})
);
app.set("etag", "strong");
// Trust exactly TRUST_PROXY proxy hops so Express derives request.ip from
// the right X-Forwarded-For entry. This is what makes request.ip
// trustworthy for rate limiting instead of a client-spoofable header.
app.set("trust proxy", config.TRUST_PROXY);
// Trust the proxies declared in TRUST_PROXY (subnet list or legacy hop
// count) so Express derives request.ip from the right X-Forwarded-For
// entry. With the subnet form, Express skips every trusted address no
// matter how many entries the proxies add, so request.ip stays the real
// visitor even when Cloudflare changes how it builds the header. This is
// what makes request.ip trustworthy for rate limiting instead of a
// client-spoofable header.
app.set("trust proxy", resolveTrustProxy(config.TRUST_PROXY));
// handle session and connection
app.use(initSession());
@@ -139,16 +144,30 @@ export default async function start() {
_response: express.Response
): string {
// Use request.ip, which Express resolves from X-Forwarded-For honouring
// the configured "trust proxy" hop count. Do NOT key off the
// cf-connecting-ip header directly: when the server isn't actually behind
// Cloudflare a client can set that header to an arbitrary value per
// request and trivially bypass the rate limiter (CWE-290).
if (!request.ip && request.socket.remoteAddress) {
// the configured "trust proxy" setting. Do NOT key off the
// cf-connecting-ip header unconditionally: when the server isn't actually
// behind Cloudflare a client can set that header to an arbitrary value
// per request and trivially bypass the rate limiter (CWE-290).
let ip = request.ip;
if (!ip && request.socket.remoteAddress) {
logger.warn("request.ip is missing");
return request.socket.remoteAddress;
ip = request.socket.remoteAddress;
}
// If resolution stopped at a Cloudflare edge address (X-Forwarded-For no
// longer contains the visitor, e.g. after a Cloudflare-side change), fall
// back to cf-connecting-ip. This is safe: request.ip can only be a
// Cloudflare address when every hop up to it is trusted, i.e. the request
// genuinely traversed Cloudflare. A direct client forging the header is
// keyed by its own untrusted address instead, so the CWE-290 bypass above
// does not apply.
if (ip && isCloudflareIP(ip)) {
const cfConnectingIP = request.headers["cf-connecting-ip"];
if (typeof cfConnectingIP === "string" && cfConnectingIP) {
ip = cfConnectingIP.trim();
}
}
// remove port number from IPv4 addresses
return (request.ip || "").replace(/:\d+[^:]*$/, "");
return (ip || "").replace(/:\d+[^:]*$/, "");
}
const rate = rateLimit({