diff --git a/src/lib/__tests__/get-client-ip.test.ts b/src/lib/__tests__/get-client-ip.test.ts index 290a2d6ab..e56e586f7 100644 --- a/src/lib/__tests__/get-client-ip.test.ts +++ b/src/lib/__tests__/get-client-ip.test.ts @@ -101,6 +101,40 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => { expect(ip).toBe("5.6.7.8"); }); + // v1.4.38 — looksLikeIp now defers to node:net's `isIP` instead of + // a hex/dot character-class regex. Structurally invalid candidates + // (`:::`, `1.2`, `gg:hh::1`) used to slip through the regex and + // pollute the rate-limit / audit log with non-IP strings; they are + // now rejected at the helper. + it("rejects structurally invalid IPv4 candidates (1.2)", () => { + const ip = getClientIp( + makeRequest({ "x-forwarded-for": "1.2, 5.6.7.8" }), + ); + expect(ip).toBe("5.6.7.8"); + }); + + it("rejects structurally invalid IPv6 candidates (:::)", () => { + const ip = getClientIp( + makeRequest({ "x-forwarded-for": ":::, 5.6.7.8" }), + ); + expect(ip).toBe("5.6.7.8"); + }); + + it("rejects hex-but-not-IPv6 chains", () => { + const ip = getClientIp( + makeRequest({ "x-forwarded-for": "gg:hh::1, 5.6.7.8" }), + ); + expect(ip).toBe("5.6.7.8"); + }); + + it("accepts well-formed IPv6 addresses", () => { + process.env.TRUST_PROXY_HOPS = "1"; + const ip = getClientIp( + makeRequest({ "x-forwarded-for": "2001:db8::1" }), + ); + expect(ip).toBe("2001:db8::1"); + }); + it("falls back to x-real-ip if XFF missing", () => { const ip = getClientIp(makeRequest({ "x-real-ip": "5.6.7.8" })); expect(ip).toBe("5.6.7.8"); diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 84314574c..4245696b8 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -1,3 +1,4 @@ +import { isIP } from "node:net"; import { NextResponse } from "next/server"; export function apiSuccess(data: T, status = 200) { @@ -86,8 +87,18 @@ export async function safeJson( * * Returns the resolved IP or null when no trusted source is available. */ +/** + * v1.4.38 — strict IP validation via Node's built-in `net.isIP`. The + * earlier regex `/^[0-9a-fA-F.:]+$/` matched any structurally-broken + * input the character set accepted (`:::`, `1.2`, `1.2.3`, `gg:hh::`). + * The cf-connecting-ip flow trusts the header under the env flag and + * the trusted-proxy XFF chain forwards the rightmost entry straight + * to the rate-limiter + audit log; a malformed value would land + * downstream unchanged. `isIP` returns 4 / 6 for valid v4 / v6 and 0 + * for anything else — invert to a boolean for the helper's surface. + */ function looksLikeIp(s: string): boolean { - return /^[0-9a-fA-F.:]+$/.test(s) && s.length >= 3 && s.length <= 45; + return isIP(s) !== 0; } /**