mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-07-22 21:10:54 +02:00
6a820408a1
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>
143 lines
5.2 KiB
JavaScript
143 lines
5.2 KiB
JavaScript
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");
|
|
});
|
|
});
|
|
});
|