Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions nemoclaw/src/blueprint/ssrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IPv6 expectations here treat link-local (fe80::/10), multicast (ff00::/8), and the unspecified address (::/128) as “public”. For SSRF protection these ranges are non-globally-routable and are commonly treated as internal/reserved, so allowing them can enable access to local interfaces/services. Consider updating isPrivateIp/PRIVATE_NETWORKS to block these reserved ranges and adjust these tests to expect rejection (private=true) accordingly.

Suggested change
// 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],
// link-local, multicast, and unspecified are treated as private/internal for SSRF protection
["fe80::1", true],
["ff02::1", true],
// zero address
["::0", true],

Copilot uses AI. Check for mistakes.
// 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

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts fc00::/8 (via fcff::1) is allowed, but RFC 4193 Unique Local Addresses are fc00::/7 (covering both fc00::/8 and fd00::/8) and are typically considered private/internal for SSRF defenses. Consider expanding the IPv6 private range to fc00::/7 and updating this expectation to true (and adding a boundary test for fbff:: vs fc00:: if you want precision similar to the IPv4 cases).

Suggested change
// 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
// fc00::/7 (RFC 4193 Unique Local Addresses) boundaries
["fbff::1", false], // just below fc00::/7
["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

Copilot uses AI. Check for mistakes.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
])("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,<h1>hi</h1>")).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 () => {

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name is misleading: https://user:pass@api.example.com/v1 is userinfo/basic auth in the URL (not “in hostname”). Renaming the test description to reflect userinfo will make it clearer what behavior is being validated.

Suggested change
it("allows URL with basic auth in hostname", async () => {
it("allows URL with userinfo/basic auth", async () => {

Copilot uses AI. Check for mistakes.
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);
});
});
Loading