fix: harden sandbox media reads against TOCTOU escapes

This commit is contained in:
Peter Steinberger
2026-03-02 01:03:40 +00:00
parent 4320cde91d
commit c823a85302
12 changed files with 223 additions and 27 deletions

View File

@@ -3,8 +3,11 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
createRootScopedReadFile,
SafeOpenError,
openFileWithinRoot,
readFileWithinRoot,
readPathWithinRoot,
readLocalFileSafely,
writeFileWithinRoot,
} from "./fs-safe.js";
@@ -70,6 +73,37 @@ describe("fs-safe", () => {
).rejects.toMatchObject({ code: "outside-workspace" });
});
it("reads a file within root", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
await fs.writeFile(path.join(root, "inside.txt"), "inside");
const result = await readFileWithinRoot({
rootDir: root,
relativePath: "inside.txt",
});
expect(result.buffer.toString("utf8")).toBe("inside");
expect(result.realPath).toContain("inside.txt");
expect(result.stat.size).toBe(6);
});
it("reads an absolute path within root via readPathWithinRoot", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const insidePath = path.join(root, "absolute.txt");
await fs.writeFile(insidePath, "absolute");
const result = await readPathWithinRoot({
rootDir: root,
filePath: insidePath,
});
expect(result.buffer.toString("utf8")).toBe("absolute");
});
it("creates a root-scoped read callback", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const insidePath = path.join(root, "scoped.txt");
await fs.writeFile(insidePath, "scoped");
const readScoped = createRootScopedReadFile({ rootDir: root });
await expect(readScoped(insidePath)).resolves.toEqual(Buffer.from("scoped"));
});
it.runIf(process.platform !== "win32")("blocks symlink escapes under root", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const outside = await tempDirs.make("openclaw-fs-safe-outside-");

View File

@@ -165,6 +165,71 @@ export async function openFileWithinRoot(params: {
return opened;
}
export async function readFileWithinRoot(params: {
rootDir: string;
relativePath: string;
rejectHardlinks?: boolean;
maxBytes?: number;
}): Promise<SafeLocalReadResult> {
const opened = await openFileWithinRoot({
rootDir: params.rootDir,
relativePath: params.relativePath,
rejectHardlinks: params.rejectHardlinks,
});
try {
if (params.maxBytes !== undefined && opened.stat.size > params.maxBytes) {
throw new SafeOpenError(
"too-large",
`file exceeds limit of ${params.maxBytes} bytes (got ${opened.stat.size})`,
);
}
const buffer = await opened.handle.readFile();
return {
buffer,
realPath: opened.realPath,
stat: opened.stat,
};
} finally {
await opened.handle.close().catch(() => {});
}
}
export async function readPathWithinRoot(params: {
rootDir: string;
filePath: string;
rejectHardlinks?: boolean;
maxBytes?: number;
}): Promise<SafeLocalReadResult> {
const rootDir = path.resolve(params.rootDir);
const candidatePath = path.isAbsolute(params.filePath)
? path.resolve(params.filePath)
: path.resolve(rootDir, params.filePath);
const relativePath = path.relative(rootDir, candidatePath);
return await readFileWithinRoot({
rootDir,
relativePath,
rejectHardlinks: params.rejectHardlinks,
maxBytes: params.maxBytes,
});
}
export function createRootScopedReadFile(params: {
rootDir: string;
rejectHardlinks?: boolean;
maxBytes?: number;
}): (filePath: string) => Promise<Buffer> {
const rootDir = path.resolve(params.rootDir);
return async (filePath: string) => {
const safeRead = await readPathWithinRoot({
rootDir,
filePath,
rejectHardlinks: params.rejectHardlinks,
maxBytes: params.maxBytes,
});
return safeRead.buffer;
};
}
export async function readLocalFileSafely(params: {
filePath: string;
maxBytes?: number;

View File

@@ -0,0 +1,57 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import {
hydrateAttachmentParamsForAction,
normalizeSandboxMediaParams,
} from "./message-action-params.js";
const cfg = {} as OpenClawConfig;
const maybeIt = process.platform === "win32" ? it.skip : it;
describe("message action sandbox media hydration", () => {
maybeIt("rejects symlink retarget escapes after sandbox media normalization", async () => {
const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "msg-params-sandbox-"));
const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "msg-params-outside-"));
try {
const insideDir = path.join(sandboxRoot, "inside");
await fs.mkdir(insideDir, { recursive: true });
await fs.writeFile(path.join(insideDir, "note.txt"), "INSIDE_SECRET", "utf8");
await fs.writeFile(path.join(outsideRoot, "note.txt"), "OUTSIDE_SECRET", "utf8");
const slotLink = path.join(sandboxRoot, "slot");
await fs.symlink(insideDir, slotLink);
const args: Record<string, unknown> = {
media: "slot/note.txt",
};
const mediaPolicy = {
mode: "sandbox",
sandboxRoot,
} as const;
await normalizeSandboxMediaParams({
args,
mediaPolicy,
});
await fs.rm(slotLink, { recursive: true, force: true });
await fs.symlink(outsideRoot, slotLink);
await expect(
hydrateAttachmentParamsForAction({
cfg,
channel: "slack",
args,
action: "sendAttachment",
mediaPolicy,
}),
).rejects.toThrow(/outside workspace root|outside/i);
} finally {
await fs.rm(sandboxRoot, { recursive: true, force: true });
await fs.rm(outsideRoot, { recursive: true, force: true });
}
});
});

View File

@@ -1,4 +1,3 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js";
@@ -9,6 +8,7 @@ import type {
ChannelThreadingToolContext,
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createRootScopedReadFile } from "../../infra/fs-safe.js";
import { extensionForMime } from "../../media/mime.js";
import { parseSlackTarget } from "../../slack/targets.js";
import { parseTelegramTarget } from "../../telegram/targets.js";
@@ -210,10 +210,13 @@ function buildAttachmentMediaLoadOptions(params: {
localRoots?: readonly string[];
} {
if (params.policy.mode === "sandbox") {
const readSandboxFile = createRootScopedReadFile({
rootDir: params.policy.sandboxRoot.trim(),
});
return {
maxBytes: params.maxBytes,
sandboxValidated: true,
readFile: (filePath: string) => fs.readFile(filePath),
readFile: readSandboxFile,
};
}
return {