Skip to content

Commit 6f99488

Browse files
authored
chore(policy): harden loadPresetFromFile against oversize and symlinked inputs (#3020)
## Summary Three follow-up hardening items from PR #2077 review against `loadPresetFromFile` in `src/lib/policies.ts`: a 10 MB file-size guard, symlink rejection via `O_NOFOLLOW`, and tmp-dir cleanup in the corresponding test block. The size-and-symlink checks now use an atomic `openSync(O_RDONLY | O_NOFOLLOW) → fstatSync → readFileSync(fd)` pattern (per CodeRabbit review), so there is no TOCTOU window between the stat and the read and the kernel rejects symbolic links at open time. ## Related Issue Closes #2521 ## Changes - Add `MAX_PRESET_FILE_BYTES = 10_000_000` and reject files larger than the limit before reading their contents. - Replace the prior `existsSync` + `statSync` pair with an atomic `openSync(O_RDONLY | O_NOFOLLOW)` followed by `fstatSync` on the descriptor and `readFileSync(fd, "utf-8")`, with a `try/finally` `closeSync` on every early-return path. Symbolic links are rejected via `ELOOP` from `openSync` and surfaced with a clear error pointing the user at `realpath`. - Track and clean up `mkdtempSync` directories created by the `writeTmp` helper inside the `loadPresetFromFile` describe block via `afterEach`. - Add tests covering the new oversize-file (>10 MB) and symlink-rejection paths. ## Type of Change - [x] Code change (feature, bug fix, or refactor) - [ ] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Verification - [x] `npx prek run --all-files` passes - [x] `npm test` passes - [x] Tests added or updated for new or changed behavior - [x] No secrets, API keys, or credentials committed - [ ] Docs updated for user-facing behavior changes - [ ] `make docs` builds without warnings (doc changes only) - [ ] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- Signed-off-by: Tinson Lai <tinsonl@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Enhanced preset file validation with strict extension checking and a 10MB file size limit. * Improved security by rejecting symbolic links and enforcing stricter file access and permission checks, with clearer error messages for invalid files. * **Tests** * Added tests covering preset file size rejection and symbolic link rejection scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
1 parent 60a282d commit 6f99488

2 files changed

Lines changed: 93 additions & 15 deletions

File tree

src/lib/policies.ts

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const { loadAgent } = require("./agent-defs");
1616

1717
const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets");
1818

19+
const MAX_PRESET_FILE_BYTES = 10_000_000;
20+
1921
type PresetInfo = {
2022
file: string;
2123
name: string;
@@ -617,29 +619,63 @@ function applyPreset(
617619
*/
618620
function loadPresetFromFile(filePath: string): { presetName: string; content: string } | null {
619621
const abs = path.resolve(filePath);
620-
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
621-
console.error(` Preset file not found: ${filePath}`);
622-
return null;
623-
}
624622
if (!/\.ya?ml$/i.test(abs)) {
625623
console.error(` Preset file must be .yaml or .yml: ${filePath}`);
626624
return null;
627625
}
628-
let content: string;
629-
let parsed: PolicyValue;
626+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
627+
let fd: number;
630628
try {
631-
content = fs.readFileSync(abs, "utf-8");
632-
parsed = YAML.parse(content);
629+
fd = fs.openSync(abs, fs.constants.O_RDONLY | NOFOLLOW);
633630
} catch (err: unknown) {
634631
const code = (err as NodeJS.ErrnoException)?.code;
635-
const message = err instanceof Error ? err.message : String(err);
636-
const msg =
637-
code === "ENOENT" || code === "EACCES"
638-
? `Cannot read ${filePath}: ${message}`
639-
: `Invalid YAML in ${filePath}: ${message}`;
640-
console.error(` ${msg}`);
632+
if (code === "ELOOP" || code === "EMLINK") {
633+
console.error(
634+
` Preset file must not be a symbolic link: ${filePath} (resolve with 'realpath' and pass the target path).`,
635+
);
636+
} else if (code === "ENOENT" || code === "ENOTDIR") {
637+
console.error(` Preset file not found: ${filePath}`);
638+
} else if (code === "EACCES" || code === "EPERM") {
639+
const message = err instanceof Error ? err.message : String(err);
640+
console.error(` Cannot read ${filePath}: ${message}`);
641+
} else {
642+
const message = err instanceof Error ? err.message : String(err);
643+
console.error(` Cannot read ${filePath}: ${message}`);
644+
}
641645
return null;
642646
}
647+
let content: string;
648+
let parsed: PolicyValue;
649+
try {
650+
const stat = fs.fstatSync(fd);
651+
if (!stat.isFile()) {
652+
console.error(` Preset file not found: ${filePath}`);
653+
return null;
654+
}
655+
if (stat.size > MAX_PRESET_FILE_BYTES) {
656+
console.error(
657+
` Preset file too large: ${filePath} (${stat.size} bytes; max ${MAX_PRESET_FILE_BYTES} bytes).`,
658+
);
659+
return null;
660+
}
661+
try {
662+
const buffer = Buffer.allocUnsafe(stat.size);
663+
let offset = 0;
664+
while (offset < buffer.length) {
665+
const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, null);
666+
if (bytesRead === 0) break;
667+
offset += bytesRead;
668+
}
669+
content = buffer.toString("utf-8", 0, offset);
670+
parsed = YAML.parse(content);
671+
} catch (err: unknown) {
672+
const message = err instanceof Error ? err.message : String(err);
673+
console.error(` Invalid YAML in ${filePath}: ${message}`);
674+
return null;
675+
}
676+
} finally {
677+
fs.closeSync(fd);
678+
}
643679
if (!isPolicyDocument(parsed)) {
644680
console.error(` Preset must be a YAML mapping: ${filePath}`);
645681
return null;

test/policies.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import os from "node:os";
77
import path from "node:path";
88
import { createRequire } from "node:module";
99
import type { Interface as ReadlineInterface } from "node:readline";
10-
import { describe, it, expect, vi } from "vitest";
10+
import { afterEach, describe, it, expect, vi } from "vitest";
1111
import { spawnSync } from "node:child_process";
1212
import policies from "../dist/lib/policies";
1313
import { execTimeout } from "./helpers/timeouts";
@@ -1278,8 +1278,17 @@ Promise.resolve(require(${CLI_PATH}).mainPromise).finally(() => {
12781278
});
12791279

12801280
describe("loadPresetFromFile", () => {
1281+
const tmpDirs: string[] = [];
1282+
afterEach(() => {
1283+
while (tmpDirs.length > 0) {
1284+
const dir = tmpDirs.pop();
1285+
if (dir) fs.rmSync(dir, { recursive: true, force: true });
1286+
}
1287+
});
1288+
12811289
function writeTmp(body: string, ext = "yaml") {
12821290
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-preset-"));
1291+
tmpDirs.push(dir);
12831292
const file = path.join(dir, `custom.${ext}`);
12841293
fs.writeFileSync(file, body);
12851294
return { dir, file };
@@ -1399,6 +1408,39 @@ Promise.resolve(require(${CLI_PATH}).mainPromise).finally(() => {
13991408
errSpy.mockRestore();
14001409
}
14011410
});
1411+
1412+
it("rejects files exceeding the size limit before reading", () => {
1413+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-preset-"));
1414+
tmpDirs.push(dir);
1415+
const file = path.join(dir, "huge.yaml");
1416+
const padding = "# ".repeat(5_500_000);
1417+
fs.writeFileSync(file, `preset:\n name: huge\nnetwork_policies:\n r:\n name: r\n${padding}`);
1418+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1419+
try {
1420+
expect(policies.loadPresetFromFile(file)).toBe(null);
1421+
const msgs = errSpy.mock.calls.map((c) => c[0]);
1422+
expect(msgs.some((m) => typeof m === "string" && m.includes("too large"))).toBe(true);
1423+
} finally {
1424+
errSpy.mockRestore();
1425+
}
1426+
});
1427+
1428+
it("rejects symbolic links to a preset file", () => {
1429+
const body = "preset:\n name: link-target\nnetwork_policies:\n r:\n name: r\n";
1430+
const { dir, file } = writeTmp(body);
1431+
const linkPath = path.join(dir, "link.yaml");
1432+
fs.symlinkSync(file, linkPath);
1433+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1434+
try {
1435+
expect(policies.loadPresetFromFile(linkPath)).toBe(null);
1436+
const msgs = errSpy.mock.calls.map((c) => c[0]);
1437+
expect(msgs.some((m) => typeof m === "string" && m.includes("must not be a symbolic link"))).toBe(
1438+
true,
1439+
);
1440+
} finally {
1441+
errSpy.mockRestore();
1442+
}
1443+
});
14021444
});
14031445

14041446
describe("policy-add --from-file / --from-dir", () => {

0 commit comments

Comments
 (0)