fix(media): harden secondary local path seams

This commit is contained in:
Peter Steinberger 2026-03-23 00:28:30 -07:00
parent 4fd7feb0fd
commit 93880717f1
No known key found for this signature in database
6 changed files with 89 additions and 10 deletions

View File

@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js";
import { createUnsafeMountedSandbox } from "../../test-helpers/unsafe-mounted-sandbox.js";
import {
@ -190,6 +190,22 @@ what is this?`);
// Only 1 ref - the local path (example.com URLs are skipped)
expect(ref?.resolved).toContain("ChatGPT Image Apr 21, 2025.png");
});
it("ignores remote-host file URLs", () => {
expectNoImageReferences("See file://attacker/share/evil.png");
});
it("ignores Windows network paths from attachment-style references", () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
expectNoImageReferences(
"[media attached: \\\\attacker\\share\\photo.png (image/png)] what is this?",
);
} finally {
platformSpy.mockRestore();
}
});
});
describe("modelSupportsImages", () => {

View File

@ -1,6 +1,6 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ImageContent } from "@mariozechner/pi-ai";
import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../../../infra/local-file-access.js";
import { loadWebMedia } from "../../../media/web-media.js";
import { resolveUserPath } from "../../../utils.js";
import type { ImageSanitizationLimits } from "../../image-sanitization.js";
@ -108,6 +108,11 @@ export function detectImageReferences(prompt: string): DetectedImageRef[] {
if (!isImageExtension(trimmed)) {
return;
}
try {
assertNoWindowsNetworkPath(trimmed, "Image path");
} catch {
return;
}
seen.add(dedupeKey);
const resolved = trimmed.startsWith("~") ? resolveUserPath(trimmed) : trimmed;
refs.push({ raw: trimmed, type: "path", resolved });
@ -160,7 +165,7 @@ export function detectImageReferences(prompt: string): DetectedImageRef[] {
seen.add(dedupeKey);
// Use fileURLToPath for proper handling (e.g., file://localhost/path)
try {
const resolved = fileURLToPath(raw);
const resolved = safeFileURLToPath(raw);
refs.push({ raw, type: "path", resolved });
} catch {
// Skip malformed file:// URLs

View File

@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { resolveSandboxedMediaSource } from "./sandbox-paths.js";
@ -162,6 +162,11 @@ describe("resolveSandboxedMediaSource", () => {
media: "file:///etc/passwd",
expected: /sandbox/i,
},
{
name: "file:// URLs with remote hosts",
media: "file://attacker/share/photo.png",
expected: /remote hosts are not allowed/i,
},
{
name: "invalid file:// URLs",
media: "file://not a valid url\x00",
@ -277,4 +282,19 @@ describe("resolveSandboxedMediaSource", () => {
});
expect(result).toBe("");
});
it("rejects Windows network paths before sandbox resolution", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
await expect(
resolveSandboxedMediaSource({
media: "\\\\attacker\\share\\photo.png",
sandboxRoot: "/any/path",
}),
).rejects.toThrow(/network paths/i);
} finally {
platformSpy.mockRestore();
}
});
});

View File

@ -1,6 +1,7 @@
import os from "node:os";
import path from "node:path";
import { fileURLToPath, URL } from "node:url";
import { URL } from "node:url";
import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js";
import { assertNoPathAliasEscape, type PathAliasPolicy } from "../infra/path-alias-guards.js";
import { isPathInside } from "../infra/path-guards.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
@ -106,9 +107,11 @@ export async function resolveSandboxedMediaSource(params: {
candidate = workspaceMappedFromUrl;
} else {
try {
candidate = fileURLToPath(candidate);
} catch {
throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`);
candidate = safeFileURLToPath(candidate);
} catch (err) {
throw new Error(`Invalid file:// URL for sandboxed media: ${(err as Error).message}`, {
cause: err,
});
}
}
}
@ -119,6 +122,7 @@ export async function resolveSandboxedMediaSource(params: {
if (containerWorkspaceMapped) {
candidate = containerWorkspaceMapped;
}
assertNoWindowsNetworkPath(candidate, "Sandbox media path");
const tmpMediaPath = await resolveAllowedTmpMediaPath({
candidate,
sandboxRoot: params.sandboxRoot,

View File

@ -0,0 +1,29 @@
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it, vi } from "vitest";
import { normalizeAttachmentPath } from "./attachments.normalize.js";
describe("normalizeAttachmentPath", () => {
it("allows localhost file URLs", () => {
const localPath = path.join(os.tmpdir(), "photo.png");
const fileUrl = pathToFileURL(localPath);
fileUrl.hostname = "localhost";
expect(normalizeAttachmentPath(fileUrl.href)).toBe(localPath);
});
it("rejects remote-host file URLs", () => {
expect(normalizeAttachmentPath("file://attacker/share/photo.png")).toBeUndefined();
});
it("rejects Windows network paths", () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
expect(normalizeAttachmentPath("\\\\attacker\\share\\photo.png")).toBeUndefined();
} finally {
platformSpy.mockRestore();
}
});
});

View File

@ -1,5 +1,5 @@
import { fileURLToPath } from "node:url";
import type { MsgContext } from "../auto-reply/templating.js";
import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js";
import { getFileExtension, isAudioFileName, kindFromMime } from "../media/mime.js";
import type { MediaAttachment } from "./types.js";
@ -10,11 +10,16 @@ export function normalizeAttachmentPath(raw?: string | null): string | undefined
}
if (value.startsWith("file://")) {
try {
return fileURLToPath(value);
return safeFileURLToPath(value);
} catch {
return undefined;
}
}
try {
assertNoWindowsNetworkPath(value, "Attachment path");
} catch {
return undefined;
}
return value;
}