mirror of https://github.com/openclaw/openclaw.git
fix(media): block remote-host file URLs in loaders
This commit is contained in:
parent
abbd1b6b8a
commit
4fd7feb0fd
|
|
@ -391,6 +391,21 @@ describe("local media root guard", () => {
|
|||
expect(result.kind).toBe("image");
|
||||
});
|
||||
|
||||
it("rejects remote-host file URLs before filesystem checks", async () => {
|
||||
const realpathSpy = vi.spyOn(fs, "realpath");
|
||||
|
||||
try {
|
||||
await expect(
|
||||
loadWebMedia("file://attacker/share/evil.png", 1024 * 1024, {
|
||||
localRoots: [resolvePreferredOpenClawTmpDir()],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "invalid-file-url" });
|
||||
expect(realpathSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts win32 dev=0 stat mismatch for local file loads", async () => {
|
||||
const actualLstat = await fs.lstat(tinyPngFile);
|
||||
const actualStat = await fs.stat(tinyPngFile);
|
||||
|
|
@ -415,6 +430,23 @@ describe("local media root guard", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("rejects Windows network paths before filesystem checks", async () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const realpathSpy = vi.spyOn(fs, "realpath");
|
||||
|
||||
try {
|
||||
await expect(
|
||||
loadWebMedia("\\\\attacker\\share\\evil.png", 1024 * 1024, {
|
||||
localRoots: [resolvePreferredOpenClawTmpDir()],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "network-path-not-allowed" });
|
||||
expect(realpathSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
platformSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires readFile override for localRoots bypass", async () => {
|
||||
await expect(
|
||||
loadWebMedia(tinyPngFile, {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { SafeOpenError, readLocalFileSafely } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { type MediaKind, maxBytesForKind } from "openclaw/plugin-sdk/media-runtime";
|
||||
|
|
@ -55,10 +55,43 @@ function resolveWebMediaOptions(params: {
|
|||
};
|
||||
}
|
||||
|
||||
function isWindowsNetworkPath(filePath: string): boolean {
|
||||
if (process.platform !== "win32") {
|
||||
return false;
|
||||
}
|
||||
const normalized = filePath.replace(/\//g, "\\");
|
||||
return normalized.startsWith("\\\\?\\UNC\\") || normalized.startsWith("\\\\");
|
||||
}
|
||||
|
||||
function assertNoWindowsNetworkPath(filePath: string, label = "Path"): void {
|
||||
if (isWindowsNetworkPath(filePath)) {
|
||||
throw new Error(`${label} cannot use Windows network paths: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeFileURLToPath(fileUrl: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(fileUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid file:// URL: ${fileUrl}`);
|
||||
}
|
||||
if (parsed.protocol !== "file:") {
|
||||
throw new Error(`Invalid file:// URL: ${fileUrl}`);
|
||||
}
|
||||
if (parsed.hostname !== "" && parsed.hostname.toLowerCase() !== "localhost") {
|
||||
throw new Error(`file:// URLs with remote hosts are not allowed: ${fileUrl}`);
|
||||
}
|
||||
const filePath = fileURLToPath(parsed);
|
||||
assertNoWindowsNetworkPath(filePath, "Local file URL");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export type LocalMediaAccessErrorCode =
|
||||
| "path-not-allowed"
|
||||
| "invalid-root"
|
||||
| "invalid-file-url"
|
||||
| "network-path-not-allowed"
|
||||
| "unsafe-bypass"
|
||||
| "not-found"
|
||||
| "invalid-path"
|
||||
|
|
@ -85,6 +118,13 @@ async function assertLocalMediaAllowed(
|
|||
if (localRoots === "any") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
assertNoWindowsNetworkPath(mediaPath, "Local media path");
|
||||
} catch (err) {
|
||||
throw new LocalMediaAccessError("network-path-not-allowed", (err as Error).message, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
const roots = localRoots ?? getDefaultLocalRoots();
|
||||
// Resolve symlinks so a symlink under /tmp pointing to /etc/passwd is caught.
|
||||
let resolved: string;
|
||||
|
|
@ -248,9 +288,9 @@ async function loadWebMediaInternal(
|
|||
// Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.)
|
||||
if (mediaUrl.startsWith("file://")) {
|
||||
try {
|
||||
mediaUrl = fileURLToPath(mediaUrl);
|
||||
} catch {
|
||||
throw new LocalMediaAccessError("invalid-file-url", `Invalid file:// URL: ${mediaUrl}`);
|
||||
mediaUrl = safeFileURLToPath(mediaUrl);
|
||||
} catch (err) {
|
||||
throw new LocalMediaAccessError("invalid-file-url", (err as Error).message, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -341,6 +381,13 @@ async function loadWebMediaInternal(
|
|||
if (mediaUrl.startsWith("~")) {
|
||||
mediaUrl = resolveUserPath(mediaUrl);
|
||||
}
|
||||
try {
|
||||
assertNoWindowsNetworkPath(mediaUrl, "Local media path");
|
||||
} catch (err) {
|
||||
throw new LocalMediaAccessError("network-path-not-allowed", (err as Error).message, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
if ((sandboxValidated || localRoots === "any") && !readFileOverride) {
|
||||
throw new LocalMediaAccessError(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
function isLocalFileUrlHost(hostname: string): boolean {
|
||||
return hostname === "" || hostname.toLowerCase() === "localhost";
|
||||
}
|
||||
|
||||
export function isWindowsNetworkPath(filePath: string): boolean {
|
||||
if (process.platform !== "win32") {
|
||||
return false;
|
||||
}
|
||||
const normalized = filePath.replace(/\//g, "\\");
|
||||
return normalized.startsWith("\\\\?\\UNC\\") || normalized.startsWith("\\\\");
|
||||
}
|
||||
|
||||
export function assertNoWindowsNetworkPath(filePath: string, label = "Path"): void {
|
||||
if (isWindowsNetworkPath(filePath)) {
|
||||
throw new Error(`${label} cannot use Windows network paths: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function safeFileURLToPath(fileUrl: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(fileUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid file:// URL: ${fileUrl}`);
|
||||
}
|
||||
if (parsed.protocol !== "file:") {
|
||||
throw new Error(`Invalid file:// URL: ${fileUrl}`);
|
||||
}
|
||||
if (!isLocalFileUrlHost(parsed.hostname)) {
|
||||
throw new Error(`file:// URLs with remote hosts are not allowed: ${fileUrl}`);
|
||||
}
|
||||
const filePath = fileURLToPath(parsed);
|
||||
assertNoWindowsNetworkPath(filePath, "Local file URL");
|
||||
return filePath;
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { loadWebMedia } from "./web-media.js";
|
||||
|
||||
const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
||||
|
||||
let fixtureRoot = "";
|
||||
let tinyPngFile = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "web-media-core-"));
|
||||
tinyPngFile = path.join(fixtureRoot, "tiny.png");
|
||||
await fs.writeFile(tinyPngFile, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (fixtureRoot) {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("loadWebMedia", () => {
|
||||
it("allows localhost file URLs for local files", async () => {
|
||||
const fileUrl = pathToFileURL(tinyPngFile);
|
||||
fileUrl.hostname = "localhost";
|
||||
|
||||
const result = await loadWebMedia(fileUrl.href, {
|
||||
maxBytes: 1024 * 1024,
|
||||
localRoots: [fixtureRoot],
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("image");
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects remote-host file URLs before filesystem checks", async () => {
|
||||
const realpathSpy = vi.spyOn(fs, "realpath");
|
||||
|
||||
try {
|
||||
await expect(
|
||||
loadWebMedia("file://attacker/share/evil.png", {
|
||||
maxBytes: 1024 * 1024,
|
||||
localRoots: [fixtureRoot],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "invalid-file-url" });
|
||||
await expect(
|
||||
loadWebMedia("file://attacker/share/evil.png", {
|
||||
maxBytes: 1024 * 1024,
|
||||
localRoots: [fixtureRoot],
|
||||
}),
|
||||
).rejects.toThrow(/remote hosts are not allowed/i);
|
||||
expect(realpathSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects Windows network paths before filesystem checks", async () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const realpathSpy = vi.spyOn(fs, "realpath");
|
||||
|
||||
try {
|
||||
await expect(
|
||||
loadWebMedia("\\\\attacker\\share\\evil.png", {
|
||||
maxBytes: 1024 * 1024,
|
||||
localRoots: [fixtureRoot],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "network-path-not-allowed" });
|
||||
expect(realpathSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
platformSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { SafeOpenError, readLocalFileSafely } from "../infra/fs-safe.js";
|
||||
import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { maxBytesForKind, type MediaKind } from "./constants.js";
|
||||
|
|
@ -59,6 +59,7 @@ export type LocalMediaAccessErrorCode =
|
|||
| "path-not-allowed"
|
||||
| "invalid-root"
|
||||
| "invalid-file-url"
|
||||
| "network-path-not-allowed"
|
||||
| "unsafe-bypass"
|
||||
| "not-found"
|
||||
| "invalid-path"
|
||||
|
|
@ -85,6 +86,13 @@ async function assertLocalMediaAllowed(
|
|||
if (localRoots === "any") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
assertNoWindowsNetworkPath(mediaPath, "Local media path");
|
||||
} catch (err) {
|
||||
throw new LocalMediaAccessError("network-path-not-allowed", (err as Error).message, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
const roots = localRoots ?? getDefaultLocalRoots();
|
||||
// Resolve symlinks so a symlink under /tmp pointing to /etc/passwd is caught.
|
||||
let resolved: string;
|
||||
|
|
@ -248,9 +256,9 @@ async function loadWebMediaInternal(
|
|||
// Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.)
|
||||
if (mediaUrl.startsWith("file://")) {
|
||||
try {
|
||||
mediaUrl = fileURLToPath(mediaUrl);
|
||||
} catch {
|
||||
throw new LocalMediaAccessError("invalid-file-url", `Invalid file:// URL: ${mediaUrl}`);
|
||||
mediaUrl = safeFileURLToPath(mediaUrl);
|
||||
} catch (err) {
|
||||
throw new LocalMediaAccessError("invalid-file-url", (err as Error).message, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -341,6 +349,13 @@ async function loadWebMediaInternal(
|
|||
if (mediaUrl.startsWith("~")) {
|
||||
mediaUrl = resolveUserPath(mediaUrl);
|
||||
}
|
||||
try {
|
||||
assertNoWindowsNetworkPath(mediaUrl, "Local media path");
|
||||
} catch (err) {
|
||||
throw new LocalMediaAccessError("network-path-not-allowed", (err as Error).message, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
if ((sandboxValidated || localRoots === "any") && !readFileOverride) {
|
||||
throw new LocalMediaAccessError(
|
||||
|
|
|
|||
Loading…
Reference in New Issue