diff --git a/package-lock.json b/package-lock.json index d5b6eac9c3..2214e03790 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1499,6 +1499,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1515,6 +1516,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1531,6 +1533,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1547,6 +1550,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1563,6 +1567,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1579,6 +1584,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1595,6 +1601,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1611,6 +1618,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1627,6 +1635,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1643,6 +1652,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1659,6 +1669,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1675,6 +1686,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1691,6 +1703,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1707,6 +1720,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1723,6 +1737,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1739,6 +1754,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1755,6 +1771,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1771,6 +1788,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1787,6 +1805,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1803,6 +1822,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1819,6 +1839,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1835,6 +1856,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1851,6 +1873,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1867,6 +1890,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1883,6 +1907,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1899,6 +1924,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2315,9 +2341,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2335,9 +2358,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2355,9 +2375,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2375,9 +2392,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2395,9 +2409,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2415,9 +2426,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2435,9 +2443,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2455,9 +2460,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2475,9 +2477,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2501,9 +2500,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2527,9 +2523,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2553,9 +2546,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2579,9 +2569,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2605,9 +2592,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2631,9 +2615,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2657,9 +2638,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4906,9 +4884,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4925,9 +4900,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4944,9 +4916,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4963,9 +4932,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4982,9 +4948,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5001,9 +4964,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5207,9 +5167,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5224,9 +5181,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5241,9 +5195,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5258,9 +5209,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5275,9 +5223,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5292,9 +5237,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5309,9 +5251,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5326,9 +5265,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5343,9 +5279,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5360,9 +5293,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5377,9 +5307,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5394,9 +5321,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5411,9 +5335,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5690,9 +5611,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5709,9 +5627,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5728,9 +5643,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5747,9 +5659,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10238,9 +10147,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10261,9 +10167,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10284,9 +10187,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10307,9 +10207,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index b1b66c88df..99ae735a46 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -147,7 +147,97 @@ async function validateBearerForRateLimit(c: Context<{ Bindings: Env }>, token: } function clientIp(c: Context<{ Bindings: Env }>): string { - return c.req.header("cf-connecting-ip")?.trim() || "unknown-ip"; + const cloudflareIp = normalizeIpAddress(c.req.header("cf-connecting-ip")); + if (cloudflareIp) return cloudflareIp; + + if (!isTrustedProxyRequest(c)) return "unknown-ip"; + + const proxyCount = trustedProxyCount(c.env.RATE_LIMIT_TRUSTED_PROXY_COUNT); + return ( + firstValidIp([ + c.req.header("x-real-ip"), + trustedForwardedForIp(c.req.header("x-forwarded-for"), proxyCount), + ]) ?? "unknown-ip" + ); +} + +function isTrustedProxyRequest(c: Context<{ Bindings: Env }>): boolean { + const trustedProxies = parseTrustedProxyList(c.env.RATE_LIMIT_TRUSTED_PROXIES); + if (trustedProxies.length === 0) return false; + const chain = forwardedForCandidates(c.req.header("x-forwarded-for")) + .map((entry) => normalizeIpAddress(entry)) + .filter((entry): entry is string => Boolean(entry)); + const peer = chain[chain.length - 1]; + return peer ? trustedProxies.includes(peer) : false; +} + +function parseTrustedProxyList(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((entry) => normalizeIpAddress(entry)) + .filter((entry): entry is string => Boolean(entry)); +} + +function trustedProxyCount(value: string | undefined): number { + const parsed = Number(value?.trim()); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 1; +} + +function trustedForwardedForIp(header: string | undefined, trustedProxyCountValue: number): string | undefined { + const chain = forwardedForCandidates(header); + if (chain.length < trustedProxyCountValue) return undefined; + return chain[chain.length - trustedProxyCountValue]; +} + +function forwardedForCandidates(header: string | undefined): string[] { + if (!header?.trim()) return []; + return header.split(",").map((part) => part.trim()).filter(Boolean); +} + +function firstValidIp(candidates: Array): string | undefined { + for (const candidate of candidates) { + const valid = normalizeIpAddress(candidate); + if (valid) return valid; + } + return undefined; +} + +function normalizeIpAddress(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || !isValidIpAddress(trimmed)) return undefined; + if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed.slice(1, -1); + return trimmed; +} + +function isValidIpAddress(value: string): boolean { + return isValidIpv4(value) || isValidIpv6(value); +} + +function isValidIpv4(value: string): boolean { + const parts = value.split("."); + if (parts.length !== 4) return false; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return false; + const octet = Number(part); + if (octet < 0 || octet > 255) return false; + } + return true; +} + +function isValidIpv6(value: string): boolean { + let candidate = value; + if (candidate.startsWith("[") && candidate.endsWith("]")) candidate = candidate.slice(1, -1); + if (!candidate.includes(":") || !/^[0-9a-fA-F:.]+$/.test(candidate)) return false; + if (candidate.split("::").length > 2) return false; + const segments = candidate.split(":"); + if (segments.length > 8) return false; + let hasHexSegment = false; + for (const segment of segments) { + if (segment === "") continue; + if (!/^[0-9a-fA-F]{1,4}$/.test(segment)) return false; + hasHexSegment = true; + } + return hasHexSegment; } function isPreAuthRateLimitPath(path: string): boolean { diff --git a/src/env.d.ts b/src/env.d.ts index d1950955e2..f8c1094c0b 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -31,6 +31,8 @@ declare global { GITTENSORY_API_TOKEN: string; GITTENSORY_MCP_TOKEN: string; INTERNAL_JOB_TOKEN: string; + RATE_LIMIT_TRUSTED_PROXIES?: string; + RATE_LIMIT_TRUSTED_PROXY_COUNT?: string; } } diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 4e583216e2..6730afd088 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -141,17 +141,303 @@ describe("private-beta auth and rate limiting", () => { expect(observedKeys[0]).toMatch(/^normal:\/v1\/public\/github\/repos\/:owner\/:repo\/stats:ip:/); }); + it("keys pre-auth routes by proxy fallback headers when cf-connecting-ip is absent", async () => { + const observedKeys: string[] = []; + const env = rateLimitTestEnv({}, observedKeys); + + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-forwarded-for": "198.51.100.1" })), + "strict", + ), + ).resolves.toBeNull(); + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-forwarded-for": "198.51.100.2" })), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys).toHaveLength(2); + expect(observedKeys[0]).not.toBe(observedKeys[1]); + expect(observedKeys[0]).toMatch(/^strict:\/v1\/auth\/github\/session:ip:/); + + observedKeys.length = 0; + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-real-ip": "198.51.100.3", "x-forwarded-for": "198.51.100.2, 198.51.100.3" })), + "strict", + ), + ).resolves.toBeNull(); + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-real-ip": "198.51.100.3" })), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys).toHaveLength(2); + expect(observedKeys[0]).toBe(observedKeys[1]); + + observedKeys.length = 0; + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-real-ip": "203.0.113.44", "x-forwarded-for": "198.51.100.99" })), + "strict", + ), + ).resolves.toBeNull(); + await expect( + enforceRateLimit(fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-real-ip": "203.0.113.44" })), "strict"), + ).resolves.toBeNull(); + expect(observedKeys).toHaveLength(2); + expect(observedKeys[0]).toBe(observedKeys[1]); + + observedKeys.length = 0; + await expect( + enforceRateLimit(fakeContext(env, "/v1/auth/github/session", { "x-forwarded-for": "198.51.100.1" }), "strict"), + ).resolves.toBeNull(); + await expect( + enforceRateLimit(fakeContext(env, "/v1/auth/github/session", { "x-forwarded-for": "198.51.100.2" }), "strict"), + ).resolves.toBeNull(); + expect(observedKeys).toHaveLength(2); + expect(observedKeys[0]).toBe(observedKeys[1]); + expect(observedKeys[0]).toMatch(/^strict:\/v1\/auth\/github\/session:ip:/); + }); + + it("does not treat spoofed cf-ray as trusted proxy proof", async () => { + const observedKeys: string[] = []; + const env = createTestEnv({ + RATE_LIMITER: rateLimiterNamespace({ status: 200, body: {} }, observedKeys) as unknown as DurableObjectNamespace, + RATE_LIMIT_TRUSTED_PROXIES: TEST_TRUSTED_PROXY, + }); + + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", { "cf-ray": "attacker-controlled", "x-forwarded-for": "198.51.100.1" }), + "strict", + ), + ).resolves.toBeNull(); + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", { "cf-ray": "attacker-controlled", "x-forwarded-for": "198.51.100.2" }), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys).toHaveLength(2); + expect(observedKeys[0]).toBe(observedKeys[1]); + expect(observedKeys[0]).toMatch(/^strict:\/v1\/auth\/github\/session:ip:/); + }); + + it("keys pre-auth routes by configured trusted proxy IPs without cf-ray", async () => { + const observedKeys: string[] = []; + const env = createTestEnv({ + RATE_LIMITER: rateLimiterNamespace({ status: 200, body: {} }, observedKeys) as unknown as DurableObjectNamespace, + RATE_LIMIT_TRUSTED_PROXIES: "198.51.100.99", + RATE_LIMIT_TRUSTED_PROXY_COUNT: "2", + }); + + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", { + "x-forwarded-for": "198.51.100.2, 198.51.100.99", + "x-real-ip": "198.51.100.2", + }), + "strict", + ), + ).resolves.toBeNull(); + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", { + "x-forwarded-for": "198.51.100.2, 198.51.100.99", + }), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys).toHaveLength(2); + expect(observedKeys[0]).toBe(observedKeys[1]); + expect(observedKeys[0]).toMatch(/^strict:\/v1\/auth\/github\/session:ip:/); + }); + + it("ignores forwarded headers when configured trusted proxy peer is absent", async () => { + const observedKeys: string[] = []; + const env = createTestEnv({ + RATE_LIMITER: rateLimiterNamespace({ status: 200, body: {} }, observedKeys) as unknown as DurableObjectNamespace, + RATE_LIMIT_TRUSTED_PROXIES: "198.51.100.99", + }); + + await expect(enforceRateLimit(fakeContext(env, "/v1/auth/github/session"), "strict")).resolves.toBeNull(); + const unknownIpKey = observedKeys[0]; + + observedKeys.length = 0; + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", { "x-forwarded-for": "198.51.100.1, 198.51.100.2", "x-real-ip": "198.51.100.3" }), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys[0]).toBe(unknownIpKey); + }); + + it("ignores malformed client address headers when building rate-limit keys", async () => { + const observedKeys: string[] = []; + const env = rateLimitTestEnv({}, observedKeys); + + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ + "cf-connecting-ip": "not-an-ip", + "x-real-ip": "198.51.100.2", + "x-forwarded-for": "198.51.100.2, 198.51.100.3", + })), + "strict", + ), + ).resolves.toBeNull(); + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-real-ip": "198.51.100.2" })), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys).toHaveLength(2); + expect(observedKeys[0]).toBe(observedKeys[1]); + + observedKeys.length = 0; + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ + "x-forwarded-for": "garbage, also-not-ip", + "x-real-ip": "203.0.113.44", + })), + "strict", + ), + ).resolves.toBeNull(); + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-real-ip": "203.0.113.44" })), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys).toHaveLength(2); + expect(observedKeys[0]).toBe(observedKeys[1]); + + observedKeys.length = 0; + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", { + "cf-connecting-ip": "999.999.999.999", + "x-forwarded-for": "still-not-ip", + "x-real-ip": "also-invalid", + }), + "strict", + ), + ).resolves.toBeNull(); + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", { + "x-forwarded-for": "attacker-controlled-bucket", + }), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys).toHaveLength(2); + expect(observedKeys[0]).toBe(observedKeys[1]); + expect(observedKeys[0]).toMatch(/^strict:\/v1\/auth\/github\/session:ip:/); + + observedKeys.length = 0; + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", { + "cf-connecting-ip": "203.0.113.9", + "x-forwarded-for": "198.51.100.1", + "x-real-ip": "198.51.100.99", + }), + "strict", + ), + ).resolves.toBeNull(); + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ + "x-forwarded-for": "not-an-ip, 198.51.100.55", + })), + "strict", + ), + ).resolves.toBeNull(); + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-forwarded-for": "198.51.100.55" })), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys).toHaveLength(3); + expect(observedKeys[0]).not.toBe(observedKeys[1]); + expect(observedKeys[1]).toBe(observedKeys[2]); + + observedKeys.length = 0; + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-real-ip": "[2001:db8::1]" })), + "strict", + ), + ).resolves.toBeNull(); + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-real-ip": "2001:db8::1" })), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys).toHaveLength(2); + expect(observedKeys[0]).toBe(observedKeys[1]); + + observedKeys.length = 0; + await expect(enforceRateLimit(fakeContext(env, "/v1/auth/github/session"), "strict")).resolves.toBeNull(); + const unknownIpKey = observedKeys[0]; + + observedKeys.length = 0; + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", trustedProxyHeaders({ "x-forwarded-for": "", "x-real-ip": " " })), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys[0]).toBe(unknownIpKey); + + observedKeys.length = 0; + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", { + "cf-connecting-ip": "1.2.3.abc", + "x-forwarded-for": "256.0.0.1, 1.2.3", + "x-real-ip": "1::2::3", + }), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys[0]).toBe(unknownIpKey); + + observedKeys.length = 0; + await expect( + enforceRateLimit( + fakeContext(env, "/v1/auth/github/session", { + "x-forwarded-for": "1:2:3:4:5:6:7:8:9:0, xyz::1", + }), + "strict", + ), + ).resolves.toBeNull(); + expect(observedKeys[0]).toBe(unknownIpKey); + }); + it("enforces route limits with session and IP keys plus retry headers", async () => { const env = createTestEnv(); const noLimiter = fakeContext(env, "/v1/repos/123/pulls/456", { authorization: "Bearer session-token" }); await expect(enforceRateLimit(noLimiter, "normal")).resolves.toBeNull(); + const fallbackObservedKeys: string[] = []; const fallbackHeaders = fakeContext( - createTestEnv({ RATE_LIMITER: rateLimiterNamespace({ status: 200, body: {} }) as unknown as DurableObjectNamespace }), + rateLimitTestEnv({}, fallbackObservedKeys), "/v1/repos/JSONbored/gittensory", - { "x-forwarded-for": "198.51.100.2, 198.51.100.3" }, + trustedProxyHeaders({ "x-real-ip": "198.51.100.3", "x-forwarded-for": "198.51.100.2, 198.51.100.3" }), ); await expect(enforceRateLimit(fallbackHeaders, "normal")).resolves.toBeNull(); + expect(fallbackObservedKeys).toHaveLength(1); + expect(fallbackObservedKeys[0]).toMatch(/^normal:\/v1\/repos\/JSONbored\/gittensory:ip:/); expect(fallbackHeaders.res.headers.get("x-ratelimit-limit")).toBe("120"); expect(fallbackHeaders.res.headers.get("x-ratelimit-remaining")).toBe("120"); expect(fallbackHeaders.res.headers.get("x-ratelimit-reset")).toBeNull(); @@ -533,6 +819,15 @@ function rateLimiterNamespace(decision: { status: number; body: Record = {}, observedKeys?: string[]) { + return createTestEnv({ + RATE_LIMITER: rateLimiterNamespace({ status: 200, body: {} }, observedKeys) as unknown as DurableObjectNamespace, + RATE_LIMIT_TRUSTED_PROXIES: TEST_TRUSTED_PROXY, + RATE_LIMIT_TRUSTED_PROXY_COUNT: "2", + ...overrides, + }); +} + function fakeContext(env: Env, path: string, headers: Record = {}) { const responseHeaders = new Headers(); return { @@ -549,3 +844,16 @@ function fakeContext(env: Env, path: string, headers: Record = { }, } as unknown as import("hono").Context<{ Bindings: Env }> & { res: { headers: Headers } }; } + +const TEST_TRUSTED_PROXY = "198.51.100.99"; + +function trustedProxyHeaders(headers: Record = {}): Record { + const next = { ...headers }; + const chain = (next["x-forwarded-for"] ?? "") + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + if (!chain.includes(TEST_TRUSTED_PROXY)) chain.push(TEST_TRUSTED_PROXY); + next["x-forwarded-for"] = chain.join(", "); + return next; +}