mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-02 09:00:59 +02:00
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:
co-authored by
Claude Fable 5
parent
3f2d6d1a64
commit
6a820408a1
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user