From 96e23b6823e8b51e38f45f7d03ae0d60263bd9ed Mon Sep 17 00:00:00 2001 From: Anh Quang Nguyen <29374105+aprprprr@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:56:50 -0700 Subject: [PATCH 1/4] test(security): add SSRF validation edge-case coverage - CIDR boundary precision for all private ranges - IPv6 edge cases: link-local, multicast, fd00::/8 - IPv4-mapped IPv6 boundary tests - DNS rebinding: mixed public+private A records - URL parsing: data: URI, query params, userinfo Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nemoclaw/src/blueprint/ssrf.test.ts | 114 ++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/nemoclaw/src/blueprint/ssrf.test.ts b/nemoclaw/src/blueprint/ssrf.test.ts index bf66304b70..7981b414ff 100644 --- a/nemoclaw/src/blueprint/ssrf.test.ts +++ b/nemoclaw/src/blueprint/ssrf.test.ts @@ -176,3 +176,117 @@ describe("validateEndpointUrl", () => { await expect(validateEndpointUrl(url)).resolves.toBe(url); }); }); + +// ── Edge-case coverage ──────────────────────────────────────────── + +describe("isPrivateIp – CIDR boundary precision", () => { + it.each([ + ["172.15.255.255", false], // just below 172.16.0.0/12 + ["172.16.0.0", true], // first address in 172.16.0.0/12 + ["172.31.255.255", true], // last address in 172.16.0.0/12 + ["172.32.0.0", false], // just above 172.16.0.0/12 + ["169.253.255.255", false], // just below 169.254.0.0/16 + ["169.254.0.0", true], // first address in 169.254.0.0/16 + ["169.255.0.0", false], // just above 169.254.0.0/16 + ["10.0.0.0", true], // first address in 10.0.0.0/8 + ["11.0.0.0", false], // just above 10.0.0.0/8 + ["126.255.255.255", false], // just below 127.0.0.0/8 + ["128.0.0.0", false], // just above 127.0.0.0/8 + ["192.167.255.255", false], // just below 192.168.0.0/16 + ["192.169.0.0", false], // just above 192.168.0.0/16 + ])("boundary %s → private=%s", (ip, expected) => { + expect(isPrivateIp(ip)).toBe(expected); + }); +}); + +describe("isPrivateIp – IPv6 edge cases", () => { + it.each([ + // link-local and multicast are NOT in PRIVATE_NETWORKS — verify they're treated as public + ["fe80::1", false], + ["ff02::1", false], + // zero address + ["::0", false], + // fd00::/8 boundaries + ["fcff::1", false], // fc00::/8 is NOT protected, only fd00::/8 + ["fd00::0", true], // first address in fd00::/8 + ["fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true], // last address in fd00::/8 + ["fe00::1", false], // just above fd00::/8 + ])("IPv6 %s → private=%s", (ip, expected) => { + expect(isPrivateIp(ip)).toBe(expected); + }); + + it.each([ + "::ffff:169.254.169.254", // cloud metadata via IPv4-mapped IPv6 + "::ffff:10.255.255.255", // 10/8 upper bound via IPv4-mapped + "::ffff:172.31.0.1", // 172.16/12 via IPv4-mapped + ])("detects IPv4-mapped private address: %s", (ip) => { + expect(isPrivateIp(ip)).toBe(true); + }); + + it.each([ + "::ffff:8.8.4.4", + "::ffff:172.32.0.1", // just outside 172.16/12 + "::ffff:11.0.0.1", // just outside 10/8 + ])("allows IPv4-mapped public address: %s", (ip) => { + expect(isPrivateIp(ip)).toBe(false); + }); +}); + +describe("validateEndpointUrl – DNS rebinding", () => { + it("rejects when ANY resolved address is private (mixed A records)", async () => { + mockLookup.mockResolvedValue([ + { address: "93.184.216.34", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ]); + await expect(validateEndpointUrl("https://rebind.attacker.com/api")).rejects.toThrow( + /private\/internal address/, + ); + }); + + it("rejects when DNS returns private IPv6 among public IPv4", async () => { + mockLookup.mockResolvedValue([ + { address: "93.184.216.34", family: 4 }, + { address: "::1", family: 6 }, + ]); + await expect(validateEndpointUrl("https://rebind.attacker.com/api")).rejects.toThrow( + /private\/internal address/, + ); + }); + + it("allows when all resolved addresses are public", async () => { + mockLookup.mockResolvedValue([ + { address: "93.184.216.34", family: 4 }, + { address: "2607:f8b0:4004:800::200e", family: 6 }, + ]); + await expect(validateEndpointUrl("https://cdn.example.com/v1")).resolves.toBe( + "https://cdn.example.com/v1", + ); + }); +}); + +describe("validateEndpointUrl – URL parsing edge cases", () => { + it("rejects data: URI", async () => { + await expect(validateEndpointUrl("data:text/html,

hi

")).rejects.toThrow( + /Unsupported URL scheme/, + ); + }); + + it("allows URL with query parameters", async () => { + mockPublicDns(); + const url = "https://api.example.com/v1?key=abc&model=gpt"; + await expect(validateEndpointUrl(url)).resolves.toBe(url); + }); + + it("allows URL with fragment", async () => { + mockPublicDns(); + const url = "https://api.example.com/v1#section"; + await expect(validateEndpointUrl(url)).resolves.toBe(url); + }); + + it("allows URL with basic auth in hostname", async () => { + mockPublicDns(); + // URL parser extracts hostname correctly even with userinfo + const url = "https://user:pass@api.example.com/v1"; + await expect(validateEndpointUrl(url)).resolves.toBe(url); + }); +}); From 3607626ca7b218200749f9cb52234235c4ff9c30 Mon Sep 17 00:00:00 2001 From: Anh Quang Nguyen <29374105+areporeporepo@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:45:03 -0700 Subject: [PATCH 2/4] Update nemoclaw/src/blueprint/ssrf.test.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- nemoclaw/src/blueprint/ssrf.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nemoclaw/src/blueprint/ssrf.test.ts b/nemoclaw/src/blueprint/ssrf.test.ts index 7981b414ff..0e4557d32f 100644 --- a/nemoclaw/src/blueprint/ssrf.test.ts +++ b/nemoclaw/src/blueprint/ssrf.test.ts @@ -207,7 +207,8 @@ describe("isPrivateIp – IPv6 edge cases", () => { // zero address ["::0", false], // fd00::/8 boundaries - ["fcff::1", false], // fc00::/8 is NOT protected, only fd00::/8 + ["fc00::1", true], // first address in fc00::/7 (ULA) + ["fcff::1", true], // still within fc00::/7 ["fd00::0", true], // first address in fd00::/8 ["fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true], // last address in fd00::/8 ["fe00::1", false], // just above fd00::/8 From f642f35c639de558ec15bdb64ae0d801a3b9195a Mon Sep 17 00:00:00 2001 From: Anh Quang Nguyen <29374105+aprprprr@users.noreply.github.com> Date: Thu, 26 Mar 2026 06:53:56 -0700 Subject: [PATCH 3/4] fix(security): expand SSRF protection to block IPv6 reserved scopes Address review feedback on PR #898: - Expand ULA range from fd00::/8 to fc00::/7 (RFC 4193) in PRIVATE_NETWORKS to block the full Unique Local Address space - Add fe80::/10 (link-local), ff00::/8 (multicast), and ::/128 (unspecified) to PRIVATE_NETWORKS as non-globally-routable - Update test expectations: these scopes are now correctly treated as private/internal for SSRF protection - Add fc00::/7 boundary precision tests (fbff::1 vs fc00::1) - Rename misleading test description to 'userinfo/basic auth' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Anh Quang Nguyen <29374105+aprprprr@users.noreply.github.com> --- nemoclaw/src/blueprint/ssrf.test.ts | 23 ++++++++++++----------- nemoclaw/src/blueprint/ssrf.ts | 5 ++++- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/nemoclaw/src/blueprint/ssrf.test.ts b/nemoclaw/src/blueprint/ssrf.test.ts index 0e4557d32f..5354b74616 100644 --- a/nemoclaw/src/blueprint/ssrf.test.ts +++ b/nemoclaw/src/blueprint/ssrf.test.ts @@ -201,17 +201,18 @@ describe("isPrivateIp – CIDR boundary precision", () => { describe("isPrivateIp – IPv6 edge cases", () => { it.each([ - // link-local and multicast are NOT in PRIVATE_NETWORKS — verify they're treated as public - ["fe80::1", false], - ["ff02::1", false], + // link-local, multicast, and unspecified are treated as private/internal for SSRF protection + ["fe80::1", true], + ["ff02::1", true], // zero address - ["::0", false], - // fd00::/8 boundaries - ["fc00::1", true], // first address in fc00::/7 (ULA) - ["fcff::1", true], // still within fc00::/7 - ["fd00::0", true], // first address in fd00::/8 - ["fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true], // last address in fd00::/8 - ["fe00::1", false], // just above fd00::/8 + ["::0", true], + // fc00::/7 (RFC 4193 Unique Local Addresses) boundaries + ["fbff::1", false], // just below fc00::/7 + ["fc00::1", true], // first usable in fc00::/7 ULA range + ["fcff::1", true], // within fc00::/7 ULA range + ["fd00::0", true], // first address in fd00::/8 (within fc00::/7) + ["fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true], // last address in fc00::/7 ULA range + ["fe00::1", false], // just above fc00::/7 (link-local starts at fe80::) ])("IPv6 %s → private=%s", (ip, expected) => { expect(isPrivateIp(ip)).toBe(expected); }); @@ -284,7 +285,7 @@ describe("validateEndpointUrl – URL parsing edge cases", () => { await expect(validateEndpointUrl(url)).resolves.toBe(url); }); - it("allows URL with basic auth in hostname", async () => { + it("allows URL with userinfo/basic auth", async () => { mockPublicDns(); // URL parser extracts hostname correctly even with userinfo const url = "https://user:pass@api.example.com/v1"; diff --git a/nemoclaw/src/blueprint/ssrf.ts b/nemoclaw/src/blueprint/ssrf.ts index 88cc705c3c..c62f2339df 100644 --- a/nemoclaw/src/blueprint/ssrf.ts +++ b/nemoclaw/src/blueprint/ssrf.ts @@ -16,7 +16,10 @@ const PRIVATE_NETWORKS: CidrRange[] = [ cidr("192.168.0.0", 16), cidr("169.254.0.0", 16), cidr6("::1", 128), - cidr6("fd00::", 8), + cidr6("::", 128), + cidr6("fc00::", 7), + cidr6("fe80::", 10), + cidr6("ff00::", 8), ]; const ALLOWED_SCHEMES = new Set(["https:", "http:"]); From e1704e3e1404d31c88cdede53da3323e55ab78af Mon Sep 17 00:00:00 2001 From: Anh Quang Nguyen <29374105+aprprprr@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:48:14 -0700 Subject: [PATCH 4/4] chore: trigger CI re-run for DCO check Signed-off-by: Anh Quang Nguyen