mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-02 09:00:59 +02:00
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>
90 lines
2.9 KiB
TypeScript
90 lines
2.9 KiB
TypeScript
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");
|
|
}
|