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 Durieux
2026-07-17 06:44:21 +02:00
committed by GitHub
parent 3f2d6d1a64
commit 6a820408a1
5 changed files with 288 additions and 19 deletions
+14 -2
View File
@@ -34,7 +34,15 @@ interface Config {
S3_ENDPOINT: string | null;
S3_REGION: string | null;
STORAGE: "filesystem" | "s3";
TRUST_PROXY: number;
/**
* Value for Express "trust proxy": either a fixed hop count ("1", "2")
* or a comma-separated list of trusted subnets — named subnets
* ("loopback", "linklocal", "uniquelocal"), IPs/CIDRs, and the keyword
* "cloudflare" (expands to Cloudflare's published ranges). The subnet
* form keeps request.ip pointing at the real visitor even when the
* proxy chain gains or loses hops. See src/server/trustProxy.ts.
*/
TRUST_PROXY: string;
RATE_LIMIT: number;
}
const config: Config = {
@@ -55,7 +63,11 @@ const config: Config = {
AUTH_CALLBACK: "http://localhost:5000/github/auth",
ANONYMIZATION_MASK: "XXXX",
PORT: 5000,
TRUST_PROXY: 1,
// Trust the local reverse proxy (docker network / host nginx) and
// Cloudflare's edges by subnet rather than by hop count, so client-IP
// resolution survives Cloudflare adding or removing X-Forwarded-For
// entries. Set a plain number to restore the legacy hop-count behavior.
TRUST_PROXY: "loopback,uniquelocal,cloudflare",
RATE_LIMIT: 350,
APP_HOSTNAME: "anonymous.4open.science",
DB_USERNAME: "admin",
+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({
+89
View File
@@ -0,0 +1,89 @@
import { BlockList, isIP } from "net";
/**
* Cloudflare's published egress ranges (https://www.cloudflare.com/ips/).
* They change rarely; refresh from https://www.cloudflare.com/ips-v4 and
* https://www.cloudflare.com/ips-v6 if Cloudflare announces new ranges.
*/
export const CLOUDFLARE_IP_RANGES: readonly string[] = [
// IPv4
"173.245.48.0/20",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"141.101.64.0/18",
"108.162.192.0/18",
"190.93.240.0/20",
"188.114.96.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"162.158.0.0/15",
"104.16.0.0/13",
"104.24.0.0/14",
"172.64.0.0/13",
"131.0.72.0/22",
// IPv6
"2400:cb00::/32",
"2606:4700::/32",
"2803:f800::/32",
"2405:b500::/32",
"2405:8100::/32",
"2a06:98c0::/29",
"2c0f:f248::/32",
];
/**
* Translate the TRUST_PROXY setting into a value for Express's
* "trust proxy". Two forms are accepted:
*
* - a plain integer ("1", "2"): the legacy fixed hop count. Fragile: if the
* proxy chain gains or loses a hop (e.g. Cloudflare changes how it builds
* X-Forwarded-For), request.ip silently becomes a proxy address and the
* rate limiter starts keying every visitor on a handful of shared IPs.
* - a comma-separated list of subnets: named subnets Express understands
* ("loopback", "linklocal", "uniquelocal"), literal IPs/CIDRs, and the
* keyword "cloudflare" which expands to CLOUDFLARE_IP_RANGES. Express then
* skips every trusted address in X-Forwarded-For regardless of how many
* entries the proxies add, so request.ip stays the real visitor.
*/
export function resolveTrustProxy(value: string): number | string[] {
const trimmed = value.trim();
if (/^-?\d+$/.test(trimmed)) {
return Number(trimmed);
}
const subnets: string[] = [];
for (const token of trimmed.split(",")) {
const subnet = token.trim();
if (!subnet) continue;
if (subnet.toLowerCase() === "cloudflare") {
subnets.push(...CLOUDFLARE_IP_RANGES);
} else {
subnets.push(subnet);
}
}
return subnets;
}
const cloudflareBlockList = new BlockList();
for (const range of CLOUDFLARE_IP_RANGES) {
const [address, prefix] = range.split("/");
cloudflareBlockList.addSubnet(
address,
Number(prefix),
isIP(address) === 6 ? "ipv6" : "ipv4"
);
}
/**
* Check whether an IP belongs to Cloudflare's published ranges. Used to
* detect that client-IP resolution stopped short at a Cloudflare edge
* address (i.e. X-Forwarded-For no longer contains the visitor).
*/
export function isCloudflareIP(ip: string): boolean {
// Express may report IPv4 clients as IPv4-mapped IPv6 (::ffff:1.2.3.4);
// compare them against the IPv4 ranges.
const normalized = ip.replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/i, "");
const family = isIP(normalized);
if (family === 0) return false;
return cloudflareBlockList.check(normalized, family === 6 ? "ipv6" : "ipv4");
}