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