From 14de24c15d16553f5c625fdf69431c1f89b11fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 17:23:02 +0200 Subject: [PATCH] fix(api-response): strict IP validation via node:net.isIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The looksLikeIp helper matched any input the hex/dot character class accepted — `:::`, `1.2`, `gg:hh::1` all passed. 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, so a malformed value landed downstream unchanged. Defer to Node's built-in `isIP` which returns 4 / 6 for valid v4 / v6 and 0 for anything else. Add tests covering the structurally invalid v4 / v6 / hex shapes plus a positive case for well-formed IPv6. --- src/lib/__tests__/get-client-ip.test.ts | 34 +++++++++++++++++++++++++ src/lib/api-response.ts | 13 +++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) 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; } /**