From 4fd7feb0fd4ec16c48ed983980dba79a09b3aaf5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Mar 2026 00:26:53 -0700 Subject: [PATCH] fix(media): block remote-host file URLs in loaders --- extensions/whatsapp/src/media.test.ts | 32 +++++++++++ extensions/whatsapp/src/media.ts | 55 +++++++++++++++++-- src/infra/local-file-access.ts | 37 +++++++++++++ src/media/web-media.test.ts | 79 +++++++++++++++++++++++++++ src/media/web-media.ts | 23 ++++++-- 5 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 src/infra/local-file-access.ts create mode 100644 src/media/web-media.test.ts diff --git a/extensions/whatsapp/src/media.test.ts b/extensions/whatsapp/src/media.test.ts index ce3e98c549c..ab2be745972 100644 --- a/extensions/whatsapp/src/media.test.ts +++ b/extensions/whatsapp/src/media.test.ts @@ -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, { diff --git a/extensions/whatsapp/src/media.ts b/extensions/whatsapp/src/media.ts index 33339451ec8..1e000a2e234 100644 --- a/extensions/whatsapp/src/media.ts +++ b/extensions/whatsapp/src/media.ts @@ -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( diff --git a/src/infra/local-file-access.ts b/src/infra/local-file-access.ts new file mode 100644 index 00000000000..530fc363a29 --- /dev/null +++ b/src/infra/local-file-access.ts @@ -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; +} diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts new file mode 100644 index 00000000000..275a65a3331 --- /dev/null +++ b/src/media/web-media.test.ts @@ -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(); + } + }); +}); diff --git a/src/media/web-media.ts b/src/media/web-media.ts index 63a36586fa8..5787b174fd9 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -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(