Skip to content
Closed
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
139 changes: 132 additions & 7 deletions src/lib/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,19 +218,144 @@ function readSandboxConfig(sandboxName: string, target: AgentConfigTarget): Conf
}

// ---------------------------------------------------------------------------
// URL validation (lightweight SSRF check for config set)
// URL validation (SSRF check for config set)
//
// Comprehensive private/reserved IP detection aligned with the canonical
// implementation in nemoclaw/src/blueprint/ssrf.ts. Covers all RFC-defined
// private, loopback, link-local, CGNAT, and reserved address ranges for
// both IPv4 and IPv6, including IPv4-mapped IPv6 addresses.
//
// Ref: https://github.com/NVIDIA/NemoClaw/issues/XXXX
// ---------------------------------------------------------------------------

const PRIVATE_IP_PREFIXES = ["127.", "10.", "0.", "169.254.", "192.168."];
import { isIPv4, isIPv6 } from "node:net";

interface CidrRange {
network: Uint8Array;
prefixLen: number;
}

function parseIPv4Bytes(addr: string): Uint8Array {
const parts = addr.split(".");
return new Uint8Array(parts.map(Number));
}

function parseIPv6Bytes(addr: string): Uint8Array {
// Handle IPv4-mapped notation (e.g., ::ffff:127.0.0.1)
const lastColon = addr.lastIndexOf(":");
const tail = addr.slice(lastColon + 1);
if (tail.includes(".")) {
const ipv4Parts = tail.split(".").map(Number);
const hi = ((ipv4Parts[0] << 8) | ipv4Parts[1]).toString(16);
const lo = ((ipv4Parts[2] << 8) | ipv4Parts[3]).toString(16);
return parseIPv6Bytes(addr.slice(0, lastColon + 1) + hi + ":" + lo);
}

// Expand :: notation to full 8 groups
let groups: string[];
if (addr.includes("::")) {
const [left, right] = addr.split("::");
const leftGroups = left ? left.split(":") : [];
const rightGroups = right ? right.split(":") : [];
const missing = 8 - leftGroups.length - rightGroups.length;
groups = [...leftGroups, ...Array<string>(missing).fill("0"), ...rightGroups];
} else {
groups = addr.split(":");
}

const bytes = new Uint8Array(16);
for (let i = 0; i < 8; i++) {
const val = parseInt(groups[i], 16);
bytes[i * 2] = (val >> 8) & 0xff;
bytes[i * 2 + 1] = val & 0xff;
}
return bytes;
}

function cidr4(addr: string, prefixLen: number): CidrRange {
return { network: parseIPv4Bytes(addr), prefixLen };
}

function cidr6(addr: string, prefixLen: number): CidrRange {
return { network: parseIPv6Bytes(addr), prefixLen };
}

/**
* All RFC-defined private, loopback, link-local, and reserved address ranges.
* Aligned with nemoclaw/src/blueprint/ssrf.ts PRIVATE_NETWORKS.
*/
const PRIVATE_NETWORKS: CidrRange[] = [
cidr4("0.0.0.0", 8), // "This network" (RFC 1122)
cidr4("127.0.0.0", 8), // Loopback (RFC 1122)
cidr4("10.0.0.0", 8), // Private (RFC 1918)
cidr4("172.16.0.0", 12), // Private (RFC 1918)
cidr4("192.168.0.0", 16), // Private (RFC 1918)
cidr4("169.254.0.0", 16), // Link-local (RFC 3927)
cidr4("100.64.0.0", 10), // CGNAT shared address space (RFC 6598)
cidr4("198.18.0.0", 15), // Benchmark testing (RFC 2544)
cidr6("::1", 128), // IPv6 loopback
cidr6("::", 128), // IPv6 unspecified
cidr6("fc00::", 7), // IPv6 unique local (RFC 4193)
cidr6("fe80::", 10), // IPv6 link-local (RFC 4291)
cidr6("ff00::", 8), // IPv6 multicast (RFC 4291)
];

function ipInCidr(ipBytes: Uint8Array, range: CidrRange): boolean {
if (ipBytes.length !== range.network.length) return false;

const fullBytes = Math.floor(range.prefixLen / 8);
const remainingBits = range.prefixLen % 8;

for (let i = 0; i < fullBytes; i++) {
if (ipBytes[i] !== range.network[i]) return false;
}

if (remainingBits > 0) {
const mask = 0xff << (8 - remainingBits);
if ((ipBytes[fullBytes] & mask) !== (range.network[fullBytes] & mask)) return false;
}

const PRIVATE_IP_172_RE = /^172\.(1[6-9]|2[0-9]|3[01])\./;
return true;
}

function isIPv4Mapped(bytes: Uint8Array): boolean {
return (
bytes.length === 16 &&
bytes[10] === 0xff &&
bytes[11] === 0xff &&
bytes.slice(0, 10).every((b) => b === 0)
);
}

/**
* Check whether an IP address or hostname falls within a private/reserved
* network range. Handles IPv4, IPv6, and IPv4-mapped IPv6 addresses.
*
* The hostname parameter comes from URL.hostname which wraps IPv6
* addresses in brackets (e.g. "[::1]") — these are stripped before matching.
*/
function isPrivateIp(hostname: string): boolean {
if (hostname === "localhost" || hostname === "[::1]") return true;
for (const prefix of PRIVATE_IP_PREFIXES) {
if (hostname.startsWith(prefix)) return true;
if (hostname === "localhost") return true;

// URL.hostname wraps IPv6 in brackets — strip them for matching
const addr = hostname.replace(/^\[|\]$/g, "");
Comment on lines +338 to +341

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Block .localhost names, not just literal localhost.

localhost. and any *.localhost hostname are special-use loopback names. The exact-string check here leaves an SSRF bypass even after the CIDR expansion.

🔒 Suggested fix
 function isPrivateIp(hostname: string): boolean {
-  if (hostname === "localhost") return true;
-
-  // URL.hostname wraps IPv6 in brackets — strip them for matching
-  const addr = hostname.replace(/^\[|\]$/g, "");
+  // URL.hostname may include IPv6 brackets and a trailing dot on FQDNs.
+  const addr = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
+  if (addr === "localhost" || addr.endsWith(".localhost")) return true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (hostname === "localhost") return true;
// URL.hostname wraps IPv6 in brackets — strip them for matching
const addr = hostname.replace(/^\[|\]$/g, "");
// URL.hostname may include IPv6 brackets and a trailing dot on FQDNs.
const addr = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
if (addr === "localhost" || addr.endsWith(".localhost")) return true;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sandbox-config.ts` around lines 338 - 341, The current check only
allows the literal "localhost" and misses special-use names like "localhost."
and "*.localhost", so update the early-return logic (the block that checks
hostname and the derived addr variable) to treat any loopback name that is
exactly "localhost", "localhost." or any subdomain ending with ".localhost" as
local — i.e., perform the check after stripping IPv6 brackets on addr and return
true when addr equals "localhost" (with or without trailing dot) or when addr
ends with ".localhost".


if (isIPv4(addr)) {
const ipBytes = parseIPv4Bytes(addr);
return PRIVATE_NETWORKS.some((range) => ipInCidr(ipBytes, range));
}
if (PRIVATE_IP_172_RE.test(hostname)) return true;

if (isIPv6(addr)) {
const ipBytes = parseIPv6Bytes(addr);
// IPv4-mapped IPv6 (::ffff:x.x.x.x) — extract the embedded IPv4
// and check against IPv4 ranges
if (isIPv4Mapped(ipBytes)) {
const ipv4Bytes = ipBytes.slice(12);
return PRIVATE_NETWORKS.some((range) => ipInCidr(ipv4Bytes, range));
}
return PRIVATE_NETWORKS.some((range) => ipInCidr(ipBytes, range));
}

return false;
}

Expand Down
54 changes: 54 additions & 0 deletions test/config-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,5 +175,59 @@ describe("config set helpers", () => {
it("rejects IPv6 loopback", () => {
expect(() => validateUrlValue("http://[::1]:8080")).toThrow(/private/i);
});

// --- Newly covered ranges (previously bypassed) ---

it("rejects CGNAT shared address space (100.64.0.0/10, RFC 6598)", () => {
expect(() => validateUrlValue("http://100.64.0.1:8080")).toThrow(/private/i);
expect(() => validateUrlValue("http://100.100.100.100:80")).toThrow(/private/i);
expect(() => validateUrlValue("http://100.127.255.254:80")).toThrow(/private/i);
});

it("allows public IPs above CGNAT range (100.128.x.x)", () => {
expect(() => validateUrlValue("http://100.128.0.1:80")).not.toThrow();
});

it("rejects benchmark testing range (198.18.0.0/15, RFC 2544)", () => {
expect(() => validateUrlValue("http://198.18.0.1:80")).toThrow(/private/i);
expect(() => validateUrlValue("http://198.19.255.254:80")).toThrow(/private/i);
});

it("allows public IPs above benchmark range (198.20.x.x)", () => {
expect(() => validateUrlValue("http://198.20.0.1:80")).not.toThrow();
});

it("rejects IPv6 unique-local addresses (fc00::/7)", () => {
expect(() => validateUrlValue("http://[fc00::1]:8080")).toThrow(/private/i);
expect(() => validateUrlValue("http://[fd12:3456:789a::1]:80")).toThrow(/private/i);
});

it("rejects IPv6 link-local addresses (fe80::/10)", () => {
expect(() => validateUrlValue("http://[fe80::1]:8080")).toThrow(/private/i);
});

it("rejects IPv6 multicast addresses (ff00::/8)", () => {
expect(() => validateUrlValue("http://[ff02::1]:8080")).toThrow(/private/i);
});

it("rejects IPv4-mapped IPv6 loopback (::ffff:127.0.0.1)", () => {
expect(() => validateUrlValue("http://[::ffff:127.0.0.1]:8080")).toThrow(/private/i);
});

it("rejects IPv4-mapped IPv6 private (::ffff:10.0.0.1)", () => {
expect(() => validateUrlValue("http://[::ffff:10.0.0.1]:8080")).toThrow(/private/i);
});

it("rejects IPv4-mapped IPv6 CGNAT (::ffff:100.64.0.1)", () => {
expect(() => validateUrlValue("http://[::ffff:100.64.0.1]:80")).toThrow(/private/i);
});

it("allows public IPv6 addresses", () => {
expect(() => validateUrlValue("http://[2001:db8::1]:8080")).not.toThrow();
});

it("allows 172.32.x.x (above private 172.16-31 range)", () => {
expect(() => validateUrlValue("http://172.32.0.1:80")).not.toThrow();
});
});
});