sandbox: allow directory boundary checks for mkdirp

This commit is contained in:
glitch418x
2026-03-01 15:01:25 +03:00
committed by Peter Steinberger
parent 4fc7ecf088
commit 687f5779d1
5 changed files with 112 additions and 8 deletions

View File

@@ -2,7 +2,11 @@ import fs from "node:fs";
import path from "node:path";
import { resolveBoundaryPath, resolveBoundaryPathSync } from "./boundary-path.js";
import type { PathAliasPolicy } from "./path-alias-guards.js";
import { openVerifiedFileSync, type SafeOpenSyncFailureReason } from "./safe-open-sync.js";
import {
openVerifiedFileSync,
type SafeOpenSyncAllowedType,
type SafeOpenSyncFailureReason,
} from "./safe-open-sync.js";
type BoundaryReadFs = Pick<
typeof fs,
@@ -28,6 +32,7 @@ export type OpenBoundaryFileSyncParams = {
rootRealPath?: string;
maxBytes?: number;
rejectHardlinks?: boolean;
allowedTypes?: readonly SafeOpenSyncAllowedType[];
skipLexicalRootCheck?: boolean;
ioFs?: BoundaryReadFs;
};
@@ -74,6 +79,7 @@ export function openBoundaryFileSync(params: OpenBoundaryFileSyncParams): Bounda
resolvedPath,
rejectHardlinks: params.rejectHardlinks ?? true,
maxBytes: params.maxBytes,
allowedTypes: params.allowedTypes,
ioFs,
});
if (!opened.ok) {

View File

@@ -0,0 +1,49 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { openVerifiedFileSync } from "./safe-open-sync.js";
async function withTempDir<T>(prefix: string, run: (dir: string) => Promise<T>): Promise<T> {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
try {
return await run(dir);
} finally {
await fsp.rm(dir, { recursive: true, force: true });
}
}
describe("openVerifiedFileSync", () => {
it("rejects directories by default", async () => {
await withTempDir("openclaw-safe-open-", async (root) => {
const targetDir = path.join(root, "nested");
await fsp.mkdir(targetDir, { recursive: true });
const opened = openVerifiedFileSync({ filePath: targetDir });
expect(opened.ok).toBe(false);
if (!opened.ok) {
expect(opened.reason).toBe("validation");
}
});
});
it("accepts directories when allowedTypes includes directory", async () => {
await withTempDir("openclaw-safe-open-", async (root) => {
const targetDir = path.join(root, "nested");
await fsp.mkdir(targetDir, { recursive: true });
const opened = openVerifiedFileSync({
filePath: targetDir,
allowedTypes: ["directory"],
rejectHardlinks: true,
});
expect(opened.ok).toBe(true);
if (!opened.ok) {
return;
}
expect(opened.stat.isDirectory()).toBe(true);
fs.closeSync(opened.fd);
});
});
});

View File

@@ -7,6 +7,8 @@ export type SafeOpenSyncResult =
| { ok: true; path: string; fd: number; stat: fs.Stats }
| { ok: false; reason: SafeOpenSyncFailureReason; error?: unknown };
export type SafeOpenSyncAllowedType = "file" | "directory";
type SafeOpenSyncFs = Pick<
typeof fs,
"constants" | "lstatSync" | "realpathSync" | "openSync" | "fstatSync" | "closeSync"
@@ -28,9 +30,11 @@ export function openVerifiedFileSync(params: {
rejectPathSymlink?: boolean;
rejectHardlinks?: boolean;
maxBytes?: number;
allowedTypes?: readonly SafeOpenSyncAllowedType[];
ioFs?: SafeOpenSyncFs;
}): SafeOpenSyncResult {
const ioFs = params.ioFs ?? fs;
const allowedTypes = params.allowedTypes ?? ["file"];
const openReadFlags =
ioFs.constants.O_RDONLY |
(typeof ioFs.constants.O_NOFOLLOW === "number" ? ioFs.constants.O_NOFOLLOW : 0);
@@ -45,25 +49,29 @@ export function openVerifiedFileSync(params: {
const realPath = params.resolvedPath ?? ioFs.realpathSync(params.filePath);
const preOpenStat = ioFs.lstatSync(realPath);
if (!preOpenStat.isFile()) {
if (!isAllowedType(preOpenStat, allowedTypes)) {
return { ok: false, reason: "validation" };
}
if (params.rejectHardlinks && preOpenStat.nlink > 1) {
if (params.rejectHardlinks && preOpenStat.isFile() && preOpenStat.nlink > 1) {
return { ok: false, reason: "validation" };
}
if (params.maxBytes !== undefined && preOpenStat.size > params.maxBytes) {
if (
params.maxBytes !== undefined &&
preOpenStat.isFile() &&
preOpenStat.size > params.maxBytes
) {
return { ok: false, reason: "validation" };
}
fd = ioFs.openSync(realPath, openReadFlags);
const openedStat = ioFs.fstatSync(fd);
if (!openedStat.isFile()) {
if (!isAllowedType(openedStat, allowedTypes)) {
return { ok: false, reason: "validation" };
}
if (params.rejectHardlinks && openedStat.nlink > 1) {
if (params.rejectHardlinks && openedStat.isFile() && openedStat.nlink > 1) {
return { ok: false, reason: "validation" };
}
if (params.maxBytes !== undefined && openedStat.size > params.maxBytes) {
if (params.maxBytes !== undefined && openedStat.isFile() && openedStat.size > params.maxBytes) {
return { ok: false, reason: "validation" };
}
if (!sameFileIdentity(preOpenStat, openedStat)) {
@@ -84,3 +92,12 @@ export function openVerifiedFileSync(params: {
}
}
}
function isAllowedType(stat: fs.Stats, allowedTypes: readonly SafeOpenSyncAllowedType[]): boolean {
return allowedTypes.some((allowedType) => {
if (allowedType === "file") {
return stat.isFile();
}
return stat.isDirectory();
});
}