From 3630700ef0938b9699a55651157d71cf99a9d529 Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:17:07 +1000 Subject: [PATCH] fix(review): block the RFC 6598 CGNAT range in both safe-url twin guards ipv4IsPrivateOrLocal rejected five private/reserved IPv4 ranges but not 100.64.0.0/10 (RFC 6598 shared address space / carrier-grade NAT) -- a non-publicly-routable range cloud providers assign to internal service endpoints, exactly what this SSRF guard exists to block. A URL resolving to a 100.64.x.x-100.127.x.x host passed the IPv4 check as if it were public. Add the range check to BOTH copies of the guard -- the engine copy (packages/loopover-engine/src/review/safe-url.ts) and its live host twin (src/review/content-lane/safe-url.ts), a NAMED_TWIN_PAIR kept in lock-step by check-engine-parity.ts -- so the SSRF protection stays consistent across every call site. Boundary-tested in both files' suites. Closes #7253 --- packages/loopover-engine/src/review/safe-url.ts | 1 + src/review/content-lane/safe-url.ts | 1 + test/unit/content-lane-safe-url.test.ts | 11 +++++++++++ test/unit/safe-url-engine.test.ts | 11 +++++++++++ 4 files changed, 24 insertions(+) diff --git a/packages/loopover-engine/src/review/safe-url.ts b/packages/loopover-engine/src/review/safe-url.ts index 6395e78718..93b7622165 100644 --- a/packages/loopover-engine/src/review/safe-url.ts +++ b/packages/loopover-engine/src/review/safe-url.ts @@ -51,6 +51,7 @@ function ipv4IsPrivateOrLocal(host: string): boolean { if (a === 169 && b === 254) return true; // link-local (incl. cloud metadata 169.254.169.254) if (a === 192 && b === 168) return true; if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 shared address space (RFC 6598 CGNAT) return false; } diff --git a/src/review/content-lane/safe-url.ts b/src/review/content-lane/safe-url.ts index 6395e78718..93b7622165 100644 --- a/src/review/content-lane/safe-url.ts +++ b/src/review/content-lane/safe-url.ts @@ -51,6 +51,7 @@ function ipv4IsPrivateOrLocal(host: string): boolean { if (a === 169 && b === 254) return true; // link-local (incl. cloud metadata 169.254.169.254) if (a === 192 && b === 168) return true; if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 shared address space (RFC 6598 CGNAT) return false; } diff --git a/test/unit/content-lane-safe-url.test.ts b/test/unit/content-lane-safe-url.test.ts index 0c180171aa..1d12d9752c 100644 --- a/test/unit/content-lane-safe-url.test.ts +++ b/test/unit/content-lane-safe-url.test.ts @@ -24,6 +24,17 @@ describe("isSafeHttpUrl", () => { expect(isSafeHttpUrl("https://printer.local")).toBe(false); }); + it("rejects the RFC 6598 shared-address-space (CGNAT) range 100.64.0.0/10 (#7253)", () => { + // A URL resolving to a carrier-grade-NAT / shared-address-space host is internal, not publicly routable, + // and must be blocked like the other private ranges. Boundary-tested at the /10's inclusive edges. + expect(isSafeHttpUrl("https://100.64.0.0")).toBe(false); // lower inclusive bound + expect(isSafeHttpUrl("https://100.100.50.1")).toBe(false); // mid-range + expect(isSafeHttpUrl("https://100.127.255.255")).toBe(false); // upper inclusive bound + // Just outside the /10 in either direction must remain public/safe. + expect(isSafeHttpUrl("https://100.63.255.255")).toBe(true); // one below the range + expect(isSafeHttpUrl("https://100.128.0.0")).toBe(true); // one above the range + }); + it("rejects the RFC 6761 *.localhost loopback namespace (not just bare localhost)", () => { // RFC 6761 makes every `*.localhost` name loopback (systemd-resolved, browsers), so the bare // `=== "localhost"` check leaked sub-labelled forms; `.endsWith(".localhost")` closes them. diff --git a/test/unit/safe-url-engine.test.ts b/test/unit/safe-url-engine.test.ts index e58ee37c33..e1d3b8ed7b 100644 --- a/test/unit/safe-url-engine.test.ts +++ b/test/unit/safe-url-engine.test.ts @@ -25,6 +25,17 @@ describe("isSafeHttpUrl", () => { expect(isSafeHttpUrl("https://printer.local")).toBe(false); }); + it("rejects the RFC 6598 shared-address-space (CGNAT) range 100.64.0.0/10 (#7253)", () => { + // A URL resolving to a carrier-grade-NAT / shared-address-space host is internal, not publicly routable, + // and must be blocked like the other private ranges. Boundary-tested at the /10's inclusive edges. + expect(isSafeHttpUrl("https://100.64.0.0")).toBe(false); // lower inclusive bound + expect(isSafeHttpUrl("https://100.100.50.1")).toBe(false); // mid-range + expect(isSafeHttpUrl("https://100.127.255.255")).toBe(false); // upper inclusive bound + // Just outside the /10 in either direction must remain public/safe. + expect(isSafeHttpUrl("https://100.63.255.255")).toBe(true); // one below the range + expect(isSafeHttpUrl("https://100.128.0.0")).toBe(true); // one above the range + }); + it("rejects the RFC 6761 *.localhost loopback namespace (not just bare localhost)", () => { // RFC 6761 makes every `*.localhost` name loopback (systemd-resolved, browsers), so the bare // `=== "localhost"` check leaked sub-labelled forms; `.endsWith(".localhost")` closes them.