From f3430a1f7f7fe37b3d9b319e67a5c3505d3984b8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:52:53 +0000 Subject: [PATCH] fix(security): resolve IP spoofing vulnerability in rate limiter Co-authored-by: is0692vs <135803462+is0692vs@users.noreply.github.com> --- src/lib/__tests__/rateLimit.test.ts | 20 ++++++++++++++------ src/lib/rateLimit.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/lib/__tests__/rateLimit.test.ts b/src/lib/__tests__/rateLimit.test.ts index 1894629f..ffc22de1 100644 --- a/src/lib/__tests__/rateLimit.test.ts +++ b/src/lib/__tests__/rateLimit.test.ts @@ -97,10 +97,10 @@ describe("RateLimiter", () => { }); describe("getClientIp", () => { - it("returns the right-most x-forwarded-for IP", () => { + it("returns the right-most untrusted x-forwarded-for IP", () => { const req = new Request("http://localhost", { headers: { - "x-forwarded-for": "5.6.7.8, 9.10.11.12" + "x-forwarded-for": "5.6.7.8, 9.10.11.12, 10.0.0.1" // 10.0.0.1 is trusted proxy } }); expect(getClientIp(req)).toBe("9.10.11.12"); @@ -147,21 +147,29 @@ describe("getClientIp", () => { expect(getClientIp(req)).toBe("unknown"); }); - it("returns unknown when the right-most x-forwarded-for token is empty", () => { + it("returns the first valid untrusted IP when the right-most x-forwarded-for token is empty", () => { const req = new Request("http://localhost", { headers: { "x-forwarded-for": "5.6.7.8, " } }); - expect(getClientIp(req)).toBe("unknown"); + expect(getClientIp(req)).toBe("5.6.7.8"); }); - it("returns unknown when the right-most x-forwarded-for token is invalid", () => { + it("returns the first valid untrusted IP when the right-most x-forwarded-for token is invalid", () => { const req = new Request("http://localhost", { headers: { "x-forwarded-for": "5.6.7.8, not-an-ip" } }); - expect(getClientIp(req)).toBe("unknown"); + expect(getClientIp(req)).toBe("5.6.7.8"); + }); + it("returns the left-most IP if all are trusted proxies", () => { + const req = new Request("http://localhost", { + headers: { + "x-forwarded-for": "192.168.1.1, 10.0.0.1" + } + }); + expect(getClientIp(req)).toBe("192.168.1.1"); }); }); diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts index 2f17cf6d..d2fe0730 100644 --- a/src/lib/rateLimit.ts +++ b/src/lib/rateLimit.ts @@ -66,12 +66,35 @@ function isValidIp(value: string): boolean { } } +function isTrustedProxy(ip: string): boolean { + // Matches standard private IPv4 ranges (RFC 1918) and localhost + const privateIpv4 = /^(127\.0\.0\.1|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/; + // Matches IPv6 localhost + const privateIpv6 = /^(::1|fd|fc00::)/; + + return privateIpv4.test(ip) || privateIpv6.test(ip); +} + export function getClientIp(request: Request): string { const forwardedFor = request.headers.get("x-forwarded-for"); if (!forwardedFor) return "unknown"; - const proxyObservedIp = forwardedFor.split(",").at(-1)?.trim(); - if (proxyObservedIp && isValidIp(proxyObservedIp)) return proxyObservedIp; + const ips = forwardedFor.split(",").map(ip => ip.trim()); + + // Iterate from right to left to find the first non-trusted IP + // For this example, we assume we want to skip internal/private IPs (trusted proxies) + // and find the true client IP. + for (let i = ips.length - 1; i >= 0; i--) { + const ip = ips[i]; + if (ip && isValidIp(ip) && !isTrustedProxy(ip)) { + return ip; + } + } + + // Fallback: If all are trusted (which is unlikely for a client request, but possible if they spoof), + // or if we couldn't find a valid non-trusted one, return the left-most valid IP. + const firstIp = ips[0]; + if (firstIp && isValidIp(firstIp)) return firstIp; return "unknown"; }