mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 21:31:25 +00:00
refactor(media): unify safe local file reads
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user