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
98 changes: 92 additions & 6 deletions nemoclaw/src/blueprint/private-networks.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
// 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";
import { beforeEach, describe, expect, it, vi } from "vitest";

// In-memory fs shared with the mocked module.
const store = new Map<string, string>();
const mtimes = new Map<string, number>();
const sizes = new Map<string, number>();
let nextMtime = 1;
let existsCalls: string[] = [];
let statCalls: string[] = [];
let nowMs = 1_000;

vi.spyOn(Date, "now").mockImplementation(() => nowMs);

vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof fs>();
Expand All @@ -18,9 +25,15 @@ vi.mock("node:fs", async (importOriginal) => {
},
readFileSync: (p: string) => {
const content = store.get(p);
if (content === undefined) throw new Error(`ENOENT: ${p}`);
if (content === undefined) throw Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" });
return content;
},
statSync: (p: string) => {
statCalls.push(p);
const mtimeMs = mtimes.get(p);
if (mtimeMs === undefined) throw Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" });
return { mtimeMs, size: sizes.get(p) ?? 0 } as fs.Stats;
},
};
});

Expand Down Expand Up @@ -50,12 +63,32 @@ names:

function seedYaml(path: string, body: string): void {
store.set(path, body);
mtimes.set(path, nextMtime);
sizes.set(path, body.length);
nextMtime += 1;
}

function replaceYamlKeepingMtime(path: string, body: string): void {
const mtimeMs = mtimes.get(path);
if (mtimeMs === undefined) throw new Error(`missing seeded mtime for ${path}`);
store.set(path, body);
mtimes.set(path, mtimeMs);
sizes.set(path, body.length);
}

function advanceStatInterval(): void {
nowMs += 1_000;
}

describe("private-networks loader", () => {
beforeEach(() => {
store.clear();
mtimes.clear();
sizes.clear();
nextMtime = 1;
existsCalls = [];
statCalls = [];
nowMs = 1_000;
delete process.env.NEMOCLAW_BLUEPRINT_PATH;
resetCache();
});
Expand All @@ -66,10 +99,8 @@ describe("private-networks loader", () => {
seedYaml("/custom/path/private-networks.yaml", VALID_YAML);
const entries = getNetworkEntries();
expect(entries.ipv4).toHaveLength(2);
// resolveBlueprintPath's dev-guess probe is skipped when the env
// var is set; the only existsSync call is load()'s existence
// precheck on the final source path.
expect(existsCalls).toEqual(["/custom/path/private-networks.yaml"]);
// resolveBlueprintPath's dev-guess probe is skipped when the env var is set.
expect(existsCalls).toEqual([]);
});

it("throws a descriptive error when the YAML is missing", () => {
Expand All @@ -79,6 +110,16 @@ describe("private-networks loader", () => {
);
});

it("throws the descriptive error when the YAML disappears between stat and read", () => {
process.env.NEMOCLAW_BLUEPRINT_PATH = "/blueprint";
seedYaml("/blueprint/private-networks.yaml", VALID_YAML);
store.delete("/blueprint/private-networks.yaml");

expect(() => getNetworkEntries()).toThrow(
/private-networks\.yaml not found at \/blueprint\/private-networks\.yaml.*NEMOCLAW_BLUEPRINT_PATH/s,
);
});

it("falls back to current directory when no env var and no dev guess", () => {
seedYaml("private-networks.yaml", VALID_YAML);
const entries = getNetworkEntries();
Expand Down Expand Up @@ -256,6 +297,21 @@ describe("private-networks loader", () => {
expect(first).toBe(second);
});

it("skips file metadata checks within the stat interval", () => {
const first = getPrivateNetworks();
const statCount = statCalls.length;
seedYaml(
"/blueprint/private-networks.yaml",
"ipv4:\n - address: 8.8.8.0\n prefix: 24\n purpose: inside stat interval\nipv6: []\nnames: []\n",
);

const second = getPrivateNetworks();

expect(second).toBe(first);
expect(statCalls).toHaveLength(statCount);
expect(isPrivateHostname("8.8.8.1")).toBe(false);
});

it("resetCache forces a reload", () => {
const before = getPrivateNetworks();
resetCache();
Expand All @@ -270,6 +326,36 @@ describe("private-networks loader", () => {
expect(isPrivateHostname("10.0.0.1")).toBe(false);
});

it("reloads when private-networks.yaml mtime changes", () => {
const before = getPrivateNetworks();
seedYaml(
"/blueprint/private-networks.yaml",
"ipv4:\n - address: 8.8.8.0\n prefix: 24\n purpose: after mtime change\nipv6: []\nnames: []\n",
);
advanceStatInterval();

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("reloads when private-networks.yaml size changes without an mtime change", () => {
const before = getPrivateNetworks();
replaceYamlKeepingMtime(
"/blueprint/private-networks.yaml",
"ipv4:\n - address: 8.8.8.0\n prefix: 24\n purpose: after size-only change with same mtime\nipv6: []\nnames: []\n",
);
advanceStatInterval();

const after = getPrivateNetworks();

expect(after).not.toBe(before);
expect(isPrivateHostname("8.8.8.1")).toBe(true);
expect(isPrivateHostname("10.0.0.1")).toBe(false);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("returns true for localhost as a hostname", () => {
expect(isPrivateHostname("localhost")).toBe(true);
});
Expand Down
79 changes: 61 additions & 18 deletions nemoclaw/src/blueprint/private-networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
//
// Private-network block list for SSRF validation. Loads the canonical
// CIDR set from nemoclaw-blueprint/private-networks.yaml and builds a
// node:net BlockList on first use, then memoises. The CLI has an
// equivalent module at src/lib/private-networks.ts; the parity test at
// test/ssrf-parity.test.ts verifies both produce identical results.
// node:net BlockList on first use, then memoises until the YAML file
// source or stats (mtime/size) change. The CLI has an equivalent module
// at src/lib/private-networks.ts; the parity test at test/ssrf-parity.test.ts
// verifies both produce identical results.
//
// Path resolution mirrors loadBlueprint() in runner.ts: honour
// NEMOCLAW_BLUEPRINT_PATH when set, otherwise try the dev-checkout
Expand All @@ -14,7 +15,7 @@
// exist, so NEMOCLAW_BLUEPRINT_PATH (set by the CLI launcher) or a
// cwd-located blueprint is required at runtime.

import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
import { BlockList, isIP } from "node:net";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
Expand All @@ -39,13 +40,29 @@ export interface NetworkDocument {
}

interface LoadedNetworks {
source: string;
mtimeMs: number;
size: number;
checkedAtMs: number;
networks: NetworkDocument;
blockList: BlockList;
normalisedNames: string[];
Comment thread
1PoPTRoN marked this conversation as resolved.
}

// Keep hot SSRF checks in memory while still letting long-running plugin
// processes pick up private-network updates without a restart.
const STAT_CHECK_INTERVAL_MS = 1_000;

let cached: LoadedNetworks | null = null;

function missingPrivateNetworksError(source: string): Error {
return new Error(
`private-networks.yaml not found at ${source}. ` +
`Set NEMOCLAW_BLUEPRINT_PATH to the directory containing the blueprint, ` +
`or run from a checkout that includes nemoclaw-blueprint/.`,
);
}

function resolveBlueprintPath(): string {
const fromEnv = process.env.NEMOCLAW_BLUEPRINT_PATH;
if (fromEnv) return fromEnv;
Expand Down Expand Up @@ -130,25 +147,53 @@ function parseDocument(raw: string, source: string): NetworkDocument {
};
}

function isNodeEnoent(err: unknown): boolean {
return err instanceof Error && "code" in err && err.code === "ENOENT";
}

function readPrivateNetworksFile(source: string): string {
try {
return readFileSync(source, "utf-8");
} catch (err) {
if (isNodeEnoent(err)) throw missingPrivateNetworksError(source);
throw err;
}
}

function load(): LoadedNetworks {
if (cached) return cached;
const now = Date.now();
if (cached && now - cached.checkedAtMs < STAT_CHECK_INTERVAL_MS) return cached;

const source = join(resolveBlueprintPath(), "private-networks.yaml");
if (!existsSync(source)) {
throw new Error(
`private-networks.yaml not found at ${source}. ` +
`Set NEMOCLAW_BLUEPRINT_PATH to the directory containing the blueprint, ` +
`or run from a checkout that includes nemoclaw-blueprint/.`,
);
let mtimeMs: number;
let size: number;
try {
const stat = statSync(source);
mtimeMs = stat.mtimeMs;
size = stat.size;
} catch (err) {
if (isNodeEnoent(err)) throw missingPrivateNetworksError(source);
throw err;
}
if (cached && cached.source === source && cached.mtimeMs === mtimeMs && cached.size === size) {
cached.checkedAtMs = now;
return cached;
}
Comment thread
1PoPTRoN marked this conversation as resolved.
const networks = parseDocument(readFileSync(source, "utf-8"), source);
const networks = parseDocument(readPrivateNetworksFile(source), source);
const blockList = new BlockList();
for (const { address, prefix } of networks.ipv4) blockList.addSubnet(address, prefix, "ipv4");
for (const { address, prefix } of networks.ipv6) blockList.addSubnet(address, prefix, "ipv6");
const normalisedNames = networks.names.map((e) => e.name.replace(/\.$/, "").toLowerCase());
cached = { networks, blockList, normalisedNames };
cached = { source, mtimeMs, size, checkedAtMs: now, networks, blockList, normalisedNames };
return cached;
}

function isPrivateIpInBlockList(address: string, blockList: BlockList): boolean {
const family = isIP(address);
if (family === 0) return false;
return blockList.check(address, family === 6 ? "ipv6" : "ipv4");
}

export function getPrivateNetworks(): BlockList {
return load().blockList;
}
Expand Down Expand Up @@ -178,9 +223,7 @@ export function resetCache(): void {
* because BlockList does not extract embedded IPv4 from those forms.
*/
export function isPrivateIp(address: string): boolean {
const family = isIP(address);
if (family === 0) return false;
return getPrivateNetworks().check(address, family === 6 ? "ipv6" : "ipv4");
return isPrivateIpInBlockList(address, getPrivateNetworks());
}

/**
Expand All @@ -202,9 +245,9 @@ export function isPrivateHostname(hostname: string): boolean {
const stripped =
hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
const normalised = stripped.replace(/\.$/, "").toLowerCase();
const { normalisedNames } = load();
const { blockList, normalisedNames } = load();
for (const reserved of normalisedNames) {
if (normalised === reserved || normalised.endsWith(`.${reserved}`)) return true;
}
return isPrivateIp(normalised);
return isPrivateIpInBlockList(normalised, blockList);
}
Loading