Skip to content
Merged
119 changes: 119 additions & 0 deletions nemoclaw-blueprint/private-networks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# IPv4 and IPv6 networks that SSRF validation must reject. Consumed by:
# - src/lib/sandbox-config.ts (CLI `config set` literal-IP gate)
# - nemoclaw/src/blueprint/ssrf.ts (plugin endpoint URL validator)
# Both consumers build a node:net BlockList from this data at module load.
#
# When adding an entry, prefer the allocated prefix over the assigned
# sub-prefix (block fe80::/10, not fe80::/64) — the larger block catches
# malformed-but-parseable addresses and defends against non-conformant
# network stacks.
#
# Translation prefixes (NAT64, 6to4, Teredo, DS-Lite) are included because
# naming an address inside them reaches an IPv4 destination via the kernel,
# the CPE, or an external translator — same attack class as ::ffff:x.x.x.x,
# which node:net BlockList auto-matches against IPv4 rules (no explicit
# entry needed).
#
# Schema:
# ipv4, ipv6 — each entry has `address`, `prefix`, and a non-empty
# `purpose` describing the attack surface the block defends.
# names — each entry has `name` and a non-empty `purpose`. Match
# the exact label and any *.name subdomain, case-
# insensitive, with a trailing FQDN dot normalised away.
# The parity test enforces non-empty `purpose` on every entry so no block
# lands without a human-reviewable rationale.
#
# Scope: these blocks gate fields like `inference.endpoint` that name a
# service the gateway will actually dial on behalf of the agent. They
# are not a universal "don't-route-there" list. 240.0.0.0/4 is included
# because no legitimate inference endpoint lives there — even in setups
# that repurpose the range for internal unicast (Kubernetes distributions,
# experimental overlays), nobody points a public-facing config key at a
# 240/4 address.
ipv4:
- address: 0.0.0.0
prefix: 8
purpose: This network
- address: 10.0.0.0
prefix: 8
purpose: Private /8 (enterprise-scale)
- address: 100.64.0.0
prefix: 10
purpose: CGNAT shared address space
- address: 127.0.0.0
prefix: 8
purpose: Loopback
- address: 169.254.0.0
prefix: 16
purpose: Link-local
- address: 172.16.0.0
prefix: 12
purpose: Private /12
- address: 192.0.0.0
prefix: 24
purpose: IETF protocol assignments (includes DS-Lite)
- address: 192.0.2.0
prefix: 24
purpose: TEST-NET-1 documentation
- address: 192.168.0.0
prefix: 16
purpose: Private /16 (home/small-network default)
- address: 198.18.0.0
prefix: 15
purpose: Benchmark testing
- address: 198.51.100.0
prefix: 24
purpose: TEST-NET-2 documentation
- address: 203.0.113.0
prefix: 24
purpose: TEST-NET-3 documentation
- address: 224.0.0.0
prefix: 4
purpose: Multicast
- address: 240.0.0.0
prefix: 4
purpose: Reserved for future use (includes 255.255.255.255 limited broadcast)
ipv6:
- address: "::"
prefix: 128
purpose: Unspecified
- address: "::1"
prefix: 128
purpose: Loopback
- address: "64:ff9b::"
prefix: 96
purpose: NAT64 well-known prefix
- address: "64:ff9b:1::"
prefix: 48
purpose: NAT64 local-use prefix
- address: "100::"
prefix: 64
purpose: Discard prefix
- address: "2001::"
prefix: 32
purpose: Teredo tunneling
- address: "2001:db8::"
prefix: 32
purpose: Documentation
- address: "2002::"
prefix: 16
purpose: 6to4
- address: "fc00::"
prefix: 7
purpose: Unique local addresses
- address: "fe80::"
prefix: 10
purpose: Link-local
- address: "ff00::"
prefix: 8
purpose: Multicast
names:
- name: localhost
purpose: RFC 6761 loopback special-use name
- name: local
purpose: RFC 6762 mDNS link-local names (Bonjour / Avahi / zeroconf)
- name: internal
purpose: ICANN-reserved private-use TLD (2024-02-11 Board Resolution)
235 changes: 235 additions & 0 deletions nemoclaw/src/blueprint/private-networks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect, beforeEach, vi } from "vitest";
import type fs from "node:fs";

// In-memory fs shared with the mocked module.
const store = new Map<string, string>();
let existsCalls: string[] = [];

vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof fs>();
return {
...original,
existsSync: (p: string) => {
existsCalls.push(p);
return store.has(p);
},
readFileSync: (p: string) => {
const content = store.get(p);
if (content === undefined) throw new Error(`ENOENT: ${p}`);
return content;
},
};
});

const { getNetworkEntries, getPrivateNetworks, isPrivateHostname, resetCache } =
await import("./private-networks.js");

const VALID_YAML = `
ipv4:
- address: 10.0.0.0
prefix: 8
purpose: Private network
- address: 127.0.0.0
prefix: 8
purpose: Loopback
ipv6:
- address: "::1"
prefix: 128
purpose: Loopback
- address: "fe80::"
prefix: 10
purpose: Link-local
names:
- name: localhost
purpose: Loopback special-use name
`;

function seedYaml(path: string, body: string): void {
store.set(path, body);
}

describe("private-networks loader", () => {
beforeEach(() => {
store.clear();
existsCalls = [];
delete process.env.NEMOCLAW_BLUEPRINT_PATH;
resetCache();
});

describe("resolveBlueprintPath", () => {
it("honours NEMOCLAW_BLUEPRINT_PATH when set", () => {
process.env.NEMOCLAW_BLUEPRINT_PATH = "/custom/path";
seedYaml("/custom/path/private-networks.yaml", VALID_YAML);
const entries = getNetworkEntries();
expect(entries.ipv4).toHaveLength(2);
// existsSync is only consulted for the dev-guess fallback, which
// is skipped when the env var is set.
expect(existsCalls).toEqual([]);
});

it("falls back to current directory when no env var and no dev guess", () => {
seedYaml("private-networks.yaml", VALID_YAML);
const entries = getNetworkEntries();
expect(entries.ipv6).toHaveLength(2);
});

it("prefers the dev-checkout guess over cwd when the yaml is present", () => {
// existsSync returns true iff the path is in the store. The
// loader probes <devGuess>/private-networks.yaml — seed only
// cwd first to learn that probe path, then re-seed with the
// probe path populated so the loader prefers it over cwd.
seedYaml(
"private-networks.yaml",
"ipv4:\n - address: 8.8.8.0\n prefix: 24\n purpose: cwd sentinel\nipv6: []\nnames: []\n",
);
getNetworkEntries();
const probedFile = existsCalls[0];
expect(probedFile).toBeDefined();
expect(probedFile.endsWith("/private-networks.yaml")).toBe(true);

store.clear();
resetCache();
existsCalls = [];
seedYaml(
"private-networks.yaml",
"ipv4:\n - address: 8.8.8.0\n prefix: 24\n purpose: cwd sentinel\nipv6: []\nnames: []\n",
);
seedYaml(probedFile, VALID_YAML);
const entries = getNetworkEntries();
expect(entries.ipv4[0].address).toBe("10.0.0.0");
});

it("falls back to cwd when the dev-guess yaml is missing", () => {
// A stale dev checkout (directory present, yaml absent) must not
// be selected — otherwise load() fails on readFileSync instead
// of falling through to cwd.
seedYaml(
"private-networks.yaml",
"ipv4:\n - address: 8.8.8.0\n prefix: 24\n purpose: cwd fallback\nipv6: []\nnames: []\n",
);
const entries = getNetworkEntries();
expect(entries.ipv4[0].address).toBe("8.8.8.0");
});
});

describe("schema validation", () => {
beforeEach(() => {
process.env.NEMOCLAW_BLUEPRINT_PATH = "/blueprint";
});

it("rejects a document missing the ipv4 array", () => {
seedYaml("/blueprint/private-networks.yaml", "ipv6: []\nnames: []\n");
expect(() => getNetworkEntries()).toThrow(
/expected top-level 'ipv4', 'ipv6', and 'names' arrays/,
);
});

it("rejects a document where ipv4 is not an array", () => {
seedYaml("/blueprint/private-networks.yaml", "ipv4: notanarray\nipv6: []\nnames: []\n");
expect(() => getNetworkEntries()).toThrow(
/expected top-level 'ipv4', 'ipv6', and 'names' arrays/,
);
});

it("rejects a document missing the names array", () => {
seedYaml("/blueprint/private-networks.yaml", "ipv4: []\nipv6: []\n");
expect(() => getNetworkEntries()).toThrow(
/expected top-level 'ipv4', 'ipv6', and 'names' arrays/,
);
});

it("rejects an ipv4 entry that is not an object", () => {
seedYaml("/blueprint/private-networks.yaml", "ipv4:\n - 42\nipv6: []\nnames: []\n");
expect(() => getNetworkEntries()).toThrow(/expected an object/);
});

it("rejects an ipv4 entry missing address", () => {
seedYaml(
"/blueprint/private-networks.yaml",
"ipv4:\n - prefix: 8\n purpose: orphan\nipv6: []\nnames: []\n",
);
expect(() => getNetworkEntries()).toThrow(/missing or empty 'address'/);
});

it("rejects an ipv4 entry with non-integer prefix", () => {
seedYaml(
"/blueprint/private-networks.yaml",
"ipv4:\n - address: 10.0.0.0\n prefix: 8.5\n purpose: fractional\nipv6: []\nnames: []\n",
);
expect(() => getNetworkEntries()).toThrow(/must be a non-negative integer/);
});

it("rejects an ipv4 entry with an empty purpose", () => {
seedYaml(
"/blueprint/private-networks.yaml",
'ipv4:\n - address: 10.0.0.0\n prefix: 8\n purpose: " "\nipv6: []\nnames: []\n',
);
expect(() => getNetworkEntries()).toThrow(/'purpose' must be a non-empty string/);
});

it("rejects a names entry missing the name field", () => {
seedYaml(
"/blueprint/private-networks.yaml",
"ipv4: []\nipv6: []\nnames:\n - purpose: orphan\n",
);
expect(() => getNetworkEntries()).toThrow(/missing or empty 'name'/);
});

it("rejects a names entry with empty purpose", () => {
seedYaml(
"/blueprint/private-networks.yaml",
'ipv4: []\nipv6: []\nnames:\n - name: localhost\n purpose: ""\n',
);
expect(() => getNetworkEntries()).toThrow(/'purpose' must be a non-empty string/);
});
});

describe("getPrivateNetworks and isPrivateHostname", () => {
beforeEach(() => {
process.env.NEMOCLAW_BLUEPRINT_PATH = "/blueprint";
seedYaml("/blueprint/private-networks.yaml", VALID_YAML);
});

it("caches the BlockList across calls", () => {
const first = getPrivateNetworks();
const second = getPrivateNetworks();
expect(first).toBe(second);
});

it("resetCache forces a reload", () => {
const before = getPrivateNetworks();
resetCache();
// Change the YAML so a reload can be detected through isPrivateHostname.
seedYaml(
"/blueprint/private-networks.yaml",
"ipv4:\n - address: 8.8.8.0\n prefix: 24\n purpose: after reset\nipv6: []\nnames: []\n",
);
const after = getPrivateNetworks();
expect(after).not.toBe(before);
expect(isPrivateHostname("8.8.8.1")).toBe(true);
expect(isPrivateHostname("10.0.0.1")).toBe(false);
});

it("returns true for localhost as a hostname", () => {
expect(isPrivateHostname("localhost")).toBe(true);
});

it("strips IPv6 brackets before matching", () => {
expect(isPrivateHostname("[::1]")).toBe(true);
expect(isPrivateHostname("[fe80::1]")).toBe(true);
expect(isPrivateHostname("[2606:4700::1]")).toBe(false);
});

it("returns false for a bare DNS name", () => {
expect(isPrivateHostname("example.com")).toBe(false);
});

it("returns false for garbage input", () => {
expect(isPrivateHostname("not-an-ip")).toBe(false);
expect(isPrivateHostname("")).toBe(false);
});
});
});
Loading
Loading