refactor(media): unify safe local file reads

This commit is contained in:
Peter Steinberger
2026-02-19 10:21:13 +01:00
parent 65a7fc6de7
commit bf3f8ec428
6 changed files with 335 additions and 88 deletions

View File

@@ -7,7 +7,12 @@ import { sendVoiceMessageDiscord } from "../discord/send.js";
import * as ssrf from "../infra/net/ssrf.js";
import { optimizeImageToPng } from "../media/image-ops.js";
import { captureEnv } from "../test-utils/env.js";
import { loadWebMedia, loadWebMediaRaw, optimizeImageToJpeg } from "./media.js";
import {
LocalMediaAccessError,
loadWebMedia,
loadWebMediaRaw,
optimizeImageToJpeg,
} from "./media.js";
let fixtureRoot = "";
let fixtureFileCount = 0;
@@ -329,7 +334,7 @@ describe("local media root guard", () => {
// Explicit roots that don't contain the temp file.
await expect(
loadWebMedia(tinyPngFile, 1024 * 1024, { localRoots: ["/nonexistent-root"] }),
).rejects.toThrow(/not under an allowed directory/i);
).rejects.toMatchObject({ code: "path-not-allowed" });
});
it("allows local paths under an explicit root", async () => {
@@ -337,6 +342,21 @@ describe("local media root guard", () => {
expect(result.kind).toBe("image");
});
it("requires readFile override for localRoots bypass", async () => {
await expect(
loadWebMedia(tinyPngFile, {
maxBytes: 1024 * 1024,
localRoots: "any",
}),
).rejects.toBeInstanceOf(LocalMediaAccessError);
await expect(
loadWebMedia(tinyPngFile, {
maxBytes: 1024 * 1024,
localRoots: "any",
}),
).rejects.toMatchObject({ code: "unsafe-bypass" });
});
it("allows any path when localRoots is 'any'", async () => {
const result = await loadWebMedia(tinyPngFile, {
maxBytes: 1024 * 1024,
@@ -351,7 +371,7 @@ describe("local media root guard", () => {
loadWebMedia(tinyPngFile, 1024 * 1024, {
localRoots: [path.parse(tinyPngFile).root],
}),
).rejects.toThrow(/refuses filesystem root/i);
).rejects.toMatchObject({ code: "invalid-root" });
});
it("allows default OpenClaw state workspace and sandbox roots", async () => {
@@ -392,7 +412,7 @@ describe("local media root guard", () => {
maxBytes: 1024 * 1024,
readFile,
}),
).rejects.toThrow(/not under an allowed directory/i);
).rejects.toMatchObject({ code: "path-not-allowed" });
});
it("allows per-agent workspace-* paths with explicit local roots", async () => {

View File

@@ -1,8 +1,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { SafeOpenError, readLocalFileSafely } from "../infra/fs-safe.js";
import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js";
import { fetchRemoteMedia } from "../media/fetch.js";
import {
@@ -33,6 +34,25 @@ type WebMediaOptions = {
readFile?: (filePath: string) => Promise<Buffer>;
};
export type LocalMediaAccessErrorCode =
| "path-not-allowed"
| "invalid-root"
| "invalid-file-url"
| "unsafe-bypass"
| "not-found"
| "invalid-path"
| "not-file";
export class LocalMediaAccessError extends Error {
code: LocalMediaAccessErrorCode;
constructor(code: LocalMediaAccessErrorCode, message: string, options?: ErrorOptions) {
super(message, options);
this.code = code;
this.name = "LocalMediaAccessError";
}
}
export function getDefaultLocalRoots(): readonly string[] {
return getDefaultMediaLocalRoots();
}
@@ -65,7 +85,10 @@ async function assertLocalMediaAllowed(
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
const firstSegment = rel.split(path.sep)[0] ?? "";
if (firstSegment.startsWith("workspace-")) {
throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
throw new LocalMediaAccessError(
"path-not-allowed",
`Local media path is not under an allowed directory: ${mediaPath}`,
);
}
}
}
@@ -78,7 +101,8 @@ async function assertLocalMediaAllowed(
resolvedRoot = path.resolve(root);
}
if (resolvedRoot === path.parse(resolvedRoot).root) {
throw new Error(
throw new LocalMediaAccessError(
"invalid-root",
`Invalid localRoots entry (refuses filesystem root): ${root}. Pass a narrower directory.`,
);
}
@@ -86,7 +110,10 @@ async function assertLocalMediaAllowed(
return;
}
}
throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
throw new LocalMediaAccessError(
"path-not-allowed",
`Local media path is not under an allowed directory: ${mediaPath}`,
);
}
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
@@ -202,7 +229,7 @@ async function loadWebMediaInternal(
try {
mediaUrl = fileURLToPath(mediaUrl);
} catch {
throw new Error(`Invalid file:// URL: ${mediaUrl}`);
throw new LocalMediaAccessError("invalid-file-url", `Invalid file:// URL: ${mediaUrl}`);
}
}
@@ -295,7 +322,8 @@ async function loadWebMediaInternal(
}
if ((sandboxValidated || localRoots === "any") && !readFileOverride) {
throw new Error(
throw new LocalMediaAccessError(
"unsafe-bypass",
"Refusing localRoots bypass without readFile override. Use sandboxValidated with readFile, or pass explicit localRoots.",
);
}
@@ -306,7 +334,35 @@ async function loadWebMediaInternal(
}
// Local path
const data = readFileOverride ? await readFileOverride(mediaUrl) : await fs.readFile(mediaUrl);
let data: Buffer;
if (readFileOverride) {
data = await readFileOverride(mediaUrl);
} else {
try {
data = (await readLocalFileSafely({ filePath: mediaUrl })).buffer;
} catch (err) {
if (err instanceof SafeOpenError) {
if (err.code === "not-found") {
throw new LocalMediaAccessError("not-found", `Local media file not found: ${mediaUrl}`, {
cause: err,
});
}
if (err.code === "not-file") {
throw new LocalMediaAccessError(
"not-file",
`Local media path is not a file: ${mediaUrl}`,
{ cause: err },
);
}
throw new LocalMediaAccessError(
"invalid-path",
`Local media path is not safe to read: ${mediaUrl}`,
{ cause: err },
);
}
throw err;
}
}
const mime = await detectMime({ buffer: data, filePath: mediaUrl });
const kind = mediaKindFromMime(mime);
let fileName = path.basename(mediaUrl) || undefined;