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");
}
+13 -6
View File
@@ -38,7 +38,7 @@ describe("Config environment variable parsing", function () {
MAX_REPO_SIZE: 60000,
ENABLE_DOWNLOAD: true,
RATE_LIMIT: 350,
TRUST_PROXY: 1,
TRUST_PROXY: "loopback,uniquelocal,cloudflare",
SESSION_SECRET: "SESSION_SECRET",
CLIENT_ID: "CLIENT_ID",
APP_HOSTNAME: "anonymous.4open.science",
@@ -80,14 +80,14 @@ describe("Config environment variable parsing", function () {
});
it("handles zero correctly", function () {
const config = parseConfigFromEnv(defaults, { TRUST_PROXY: "0" });
expect(config.TRUST_PROXY).to.equal(0);
expect(config.TRUST_PROXY).to.be.a("number");
const config = parseConfigFromEnv(defaults, { RATE_LIMIT: "0" });
expect(config.RATE_LIMIT).to.equal(0);
expect(config.RATE_LIMIT).to.be.a("number");
});
it("handles negative numbers", function () {
const config = parseConfigFromEnv(defaults, { TRUST_PROXY: "-1" });
expect(config.TRUST_PROXY).to.equal(-1);
const config = parseConfigFromEnv(defaults, { RATE_LIMIT: "-1" });
expect(config.RATE_LIMIT).to.equal(-1);
});
it("correctly compares parsed numbers (no string comparison bug)", function () {
@@ -163,6 +163,13 @@ describe("Config environment variable parsing", function () {
const config = parseConfigFromEnv(defaults, { STORAGE: "s3" });
expect(config.STORAGE).to.equal("s3");
});
it("keeps TRUST_PROXY as a string (legacy hop counts included)", function () {
// resolveTrustProxy() interprets the string later; a numeric env value
// like "2" must survive as-is.
const config = parseConfigFromEnv(defaults, { TRUST_PROXY: "2" });
expect(config.TRUST_PROXY).to.equal("2");
});
});
// ---------------------------------------------------------------
+142
View File
@@ -0,0 +1,142 @@
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const {
resolveTrustProxy,
isCloudflareIP,
CLOUDFLARE_IP_RANGES,
} = require("../src/server/trustProxy");
describe("trustProxy", function () {
describe("resolveTrustProxy", function () {
it("parses a plain integer as a legacy hop count", function () {
expect(resolveTrustProxy("1")).to.equal(1);
expect(resolveTrustProxy("2")).to.equal(2);
expect(resolveTrustProxy(" 3 ")).to.equal(3);
});
it("parses zero as a number", function () {
expect(resolveTrustProxy("0")).to.equal(0);
});
it("expands the cloudflare keyword to the published ranges", function () {
const result = resolveTrustProxy("cloudflare");
expect(result).to.be.an("array");
expect(result).to.have.members([...CLOUDFLARE_IP_RANGES]);
});
it("keeps named subnets and mixes them with cloudflare ranges", function () {
const result = resolveTrustProxy("loopback,uniquelocal,cloudflare");
expect(result).to.include("loopback");
expect(result).to.include("uniquelocal");
expect(result).to.include("173.245.48.0/20");
expect(result).to.include("2400:cb00::/32");
expect(result).to.have.length(2 + CLOUDFLARE_IP_RANGES.length);
});
it("accepts literal CIDRs and trims whitespace and empty tokens", function () {
const result = resolveTrustProxy(" 10.0.0.0/8 , , 192.168.1.1 ");
expect(result).to.deep.equal(["10.0.0.0/8", "192.168.1.1"]);
});
it("is case-insensitive for the cloudflare keyword", function () {
const result = resolveTrustProxy("Cloudflare");
expect(result).to.have.length(CLOUDFLARE_IP_RANGES.length);
});
});
describe("isCloudflareIP", function () {
it("matches IPv4 addresses inside Cloudflare ranges", function () {
expect(isCloudflareIP("104.16.132.229")).to.be.true; // 104.16.0.0/13
expect(isCloudflareIP("172.64.0.1")).to.be.true; // 172.64.0.0/13
expect(isCloudflareIP("173.245.48.10")).to.be.true; // 173.245.48.0/20
});
it("matches IPv6 addresses inside Cloudflare ranges", function () {
expect(isCloudflareIP("2400:cb00::1")).to.be.true;
expect(isCloudflareIP("2606:4700:4700::1111")).to.be.true;
});
it("matches IPv4-mapped IPv6 representations", function () {
expect(isCloudflareIP("::ffff:104.16.0.1")).to.be.true;
});
it("rejects addresses outside Cloudflare ranges", function () {
expect(isCloudflareIP("8.8.8.8")).to.be.false;
expect(isCloudflareIP("127.0.0.1")).to.be.false;
expect(isCloudflareIP("192.168.1.1")).to.be.false;
expect(isCloudflareIP("2001:4860:4860::8888")).to.be.false;
expect(isCloudflareIP("::ffff:8.8.8.8")).to.be.false;
});
it("rejects garbage input without throwing", function () {
expect(isCloudflareIP("")).to.be.false;
expect(isCloudflareIP("not-an-ip")).to.be.false;
expect(isCloudflareIP("104.16.0")).to.be.false;
});
});
describe("integration with Express trust proxy resolution", function () {
const express = require("express");
function requestIp(trustProxy, headers) {
const app = express();
app.set("trust proxy", trustProxy);
let captured = null;
app.get("/", (req, res) => {
captured = req.ip;
res.end();
});
return new Promise((resolve, reject) => {
const server = app.listen(0, "127.0.0.1", () => {
const { port } = server.address();
const http = require("http");
const req = http.get(
{ host: "127.0.0.1", port, path: "/", headers },
(res) => {
res.resume();
res.on("end", () => {
server.close();
resolve(captured);
});
}
);
req.on("error", (err) => {
server.close();
reject(err);
});
});
});
}
it("resolves the visitor IP through Cloudflare hops regardless of count", async function () {
const trust = resolveTrustProxy("loopback,uniquelocal,cloudflare");
// Simulate: visitor -> Cloudflare edge -> local nginx -> app, where
// Cloudflare appended an extra internal hop to X-Forwarded-For.
const ip = await requestIp(trust, {
"x-forwarded-for": "203.0.113.7, 104.16.0.9, 172.64.0.3",
});
expect(ip).to.equal("203.0.113.7");
});
it("does not honour X-Forwarded-For from an untrusted public peer entry", async function () {
const trust = resolveTrustProxy("loopback,uniquelocal,cloudflare");
// The last entry is a non-Cloudflare public address: resolution stops
// there, so a forged visitor entry further left is ignored.
const ip = await requestIp(trust, {
"x-forwarded-for": "1.2.3.4, 8.8.8.8",
});
expect(ip).to.equal("8.8.8.8");
});
it("legacy hop count still works", async function () {
const ip = await requestIp(resolveTrustProxy("1"), {
"x-forwarded-for": "203.0.113.7, 104.16.0.9",
});
// hop count 1 trusts only the direct peer, so req.ip is the last
// X-Forwarded-For entry — the fragile behavior this change moves
// away from.
expect(ip).to.equal("104.16.0.9");
});
});
});