fix(media): block remote-host file URLs in loaders

This commit is contained in:
Peter Steinberger
2026-03-23 00:26:53 -07:00
parent abbd1b6b8a
commit 4fd7feb0fd
5 changed files with 218 additions and 8 deletions

View File

@@ -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, {

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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();
}
});
});

View File

@@ -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(