mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 00:30:23 +00:00
fix(media): block remote-host file URLs in loaders
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user