Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
116 changes: 116 additions & 0 deletions nemoclaw/src/blueprint/ssrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,119 @@ 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, multicast, and unspecified are treated as private/internal for SSRF protection
["fe80::1", true],
["ff02::1", true],
// zero address
["::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);
});

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 userinfo/basic auth", 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);
});
});
5 changes: 4 additions & 1 deletion nemoclaw/src/blueprint/ssrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ const PRIVATE_NETWORKS: CidrRange[] = [
cidr("169.254.0.0", 16),
cidr("100.64.0.0", 10), // RFC 6598 CGNAT (shared address space)
cidr6("::1", 128),
cidr6("fd00::", 8),
cidr6("::", 128),
cidr6("fc00::", 7),
cidr6("fe80::", 10),
cidr6("ff00::", 8),
];

const ALLOWED_SCHEMES = new Set(["https:", "http:"]);
Expand Down
Loading