diff --git a/src/media/local-media-access.test.ts b/src/media/local-media-access.test.ts new file mode 100644 index 00000000000..749ceefc58b --- /dev/null +++ b/src/media/local-media-access.test.ts @@ -0,0 +1,36 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveStateDir } from "../config/paths.js"; +import { assertLocalMediaAllowed } from "./local-media-access.js"; + +describe("assertLocalMediaAllowed", () => { + it("allows managed inbound media paths before explicit root checks", async () => { + const stateDir = resolveStateDir(); + const id = `managed-local-${Date.now()}-${Math.random().toString(36).slice(2)}.png`; + const filePath = path.join(stateDir, "media", "inbound", id); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, Buffer.from("png")); + + try { + await expect(assertLocalMediaAllowed(filePath, [])).resolves.toBeUndefined(); + } finally { + await fs.rm(filePath, { force: true }); + } + }); + + it("does not allow nested inbound paths as managed media", async () => { + const stateDir = resolveStateDir(); + const filePath = path.join(stateDir, "media", "inbound", "nested", "hidden.png"); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, Buffer.from("png")); + + try { + await expect(assertLocalMediaAllowed(filePath, [])).rejects.toMatchObject({ + code: "path-not-allowed", + }); + } finally { + await fs.rm(path.dirname(filePath), { recursive: true, force: true }); + } + }); +}); diff --git a/src/media/local-media-access.ts b/src/media/local-media-access.ts index 2a1653e4b37..cc3f7ac7f2d 100644 --- a/src/media/local-media-access.ts +++ b/src/media/local-media-access.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { assertNoWindowsNetworkPath } from "../infra/local-file-access.js"; import { getDefaultMediaLocalRoots } from "./local-roots.js"; +import { resolveInboundMediaReference } from "./media-reference.js"; export type LocalMediaAccessErrorCode = | "path-not-allowed" @@ -34,6 +35,10 @@ export async function assertLocalMediaAllowed( if (localRoots === "any") { return; } + const inboundReference = await resolveInboundMediaReference(mediaPath).catch(() => null); + if (inboundReference) { + return; + } try { assertNoWindowsNetworkPath(mediaPath, "Local media path"); } catch (err) { diff --git a/src/media/media-reference.test.ts b/src/media/media-reference.test.ts new file mode 100644 index 00000000000..ddc4e9d2a99 --- /dev/null +++ b/src/media/media-reference.test.ts @@ -0,0 +1,142 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveStateDir } from "../config/paths.js"; +import { + classifyMediaReferenceSource, + MediaReferenceError, + normalizeMediaReferenceSource, + resolveInboundMediaReference, + resolveMediaReferenceLocalPath, +} from "./media-reference.js"; + +describe("media reference helpers", () => { + it("normalizes outbound MEDIA tags without changing canonical media URIs", () => { + expect(normalizeMediaReferenceSource(" MEDIA: ./out.png")).toBe("./out.png"); + expect(normalizeMediaReferenceSource("media://inbound/a.png")).toBe("media://inbound/a.png"); + }); + + it("classifies supported and unsupported media reference schemes", () => { + expect(classifyMediaReferenceSource("media://inbound/a.png")).toMatchObject({ + isMediaStoreUrl: true, + hasUnsupportedScheme: false, + }); + expect(classifyMediaReferenceSource("data:image/png;base64,cG5n")).toMatchObject({ + isDataUrl: true, + hasUnsupportedScheme: false, + }); + expect( + classifyMediaReferenceSource("data:image/png;base64,cG5n", { allowDataUrl: false }), + ).toMatchObject({ + isDataUrl: true, + hasUnsupportedScheme: true, + }); + expect(classifyMediaReferenceSource("ftp://example.test/a.png")).toMatchObject({ + hasUnsupportedScheme: true, + }); + expect(classifyMediaReferenceSource("C:\\Users\\pete\\image.png")).toMatchObject({ + looksLikeWindowsDrivePath: true, + hasUnsupportedScheme: false, + }); + }); + + it("resolves canonical inbound media URIs", async () => { + const stateDir = resolveStateDir(); + const id = `ref-${Date.now()}-${Math.random().toString(36).slice(2)}.png`; + const filePath = path.join(stateDir, "media", "inbound", id); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, Buffer.from("png")); + + try { + await expect(resolveInboundMediaReference(`media://inbound/${id}`)).resolves.toMatchObject({ + id, + normalizedSource: `media://inbound/${id}`, + physicalPath: filePath, + sourceType: "uri", + }); + } finally { + await fs.rm(filePath, { force: true }); + } + }); + + it("maps canonical inbound media URIs to local paths for direct file readers", async () => { + const stateDir = resolveStateDir(); + const id = `ref-local-path-${Date.now()}-${Math.random().toString(36).slice(2)}.png`; + const filePath = path.join(stateDir, "media", "inbound", id); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, Buffer.from("png")); + + try { + await expect(resolveMediaReferenceLocalPath(`media://inbound/${id}`)).resolves.toBe(filePath); + await expect(resolveMediaReferenceLocalPath(" MEDIA: ./out.png")).resolves.toBe("./out.png"); + } finally { + await fs.rm(filePath, { force: true }); + } + }); + + it("resolves direct absolute paths only for first-level inbound media files", async () => { + const stateDir = resolveStateDir(); + const id = `ref-path-${Date.now()}-${Math.random().toString(36).slice(2)}.png`; + const filePath = path.join(stateDir, "media", "inbound", id); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, Buffer.from("png")); + + try { + await expect(resolveInboundMediaReference(filePath)).resolves.toMatchObject({ + id, + physicalPath: filePath, + sourceType: "path", + }); + await expect( + resolveInboundMediaReference(path.join(stateDir, "media", "inbound", "nested", id)), + ).resolves.toBeNull(); + await expect( + resolveInboundMediaReference(path.join(stateDir, "media", "outbound", id)), + ).resolves.toBeNull(); + } finally { + await fs.rm(filePath, { force: true }); + } + }); + + it("rejects inbound media URIs with unsupported locations or unsafe ids", async () => { + await expect(resolveInboundMediaReference("media://outbound/a.png")).rejects.toMatchObject({ + code: "path-not-allowed", + }); + await expect( + resolveInboundMediaReference("media://inbound/nested%2Fa.png"), + ).rejects.toBeInstanceOf(MediaReferenceError); + await expect( + resolveInboundMediaReference("media://inbound/nested%2Fa.png"), + ).rejects.toMatchObject({ code: "invalid-path" }); + await expect(resolveInboundMediaReference("media://inbound/")).rejects.toMatchObject({ + code: "invalid-path", + }); + await expect(resolveInboundMediaReference("media://inbound/%00.png")).rejects.toMatchObject({ + code: "invalid-path", + }); + }); + + it("rejects symlinked inbound media files", async () => { + const stateDir = resolveStateDir(); + const targetDir = path.join(stateDir, "media-reference-test-target"); + const targetPath = path.join(targetDir, "target.png"); + const id = `ref-link-${Date.now()}-${Math.random().toString(36).slice(2)}.png`; + const linkPath = path.join(stateDir, "media", "inbound", id); + await fs.mkdir(targetDir, { recursive: true }); + await fs.mkdir(path.dirname(linkPath), { recursive: true }); + await fs.writeFile(targetPath, Buffer.from("png")); + await fs.symlink(targetPath, linkPath); + + try { + await expect(resolveInboundMediaReference(`media://inbound/${id}`)).rejects.toMatchObject({ + code: "invalid-path", + }); + await expect(resolveInboundMediaReference(linkPath)).rejects.toMatchObject({ + code: "invalid-path", + }); + } finally { + await fs.rm(linkPath, { force: true }); + await fs.rm(targetDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/media/media-reference.ts b/src/media/media-reference.ts new file mode 100644 index 00000000000..20f8c2b495d --- /dev/null +++ b/src/media/media-reference.ts @@ -0,0 +1,181 @@ +import path from "node:path"; +import { safeFileURLToPath } from "../infra/local-file-access.js"; +import { resolveUserPath } from "../utils.js"; +import { getMediaDir, resolveMediaBufferPath } from "./store.js"; + +export type MediaReferenceErrorCode = "invalid-path" | "path-not-allowed"; + +export class MediaReferenceError extends Error { + code: MediaReferenceErrorCode; + + constructor(code: MediaReferenceErrorCode, message: string, options?: ErrorOptions) { + super(message, options); + this.code = code; + this.name = "MediaReferenceError"; + } +} + +export type InboundMediaReference = { + id: string; + normalizedSource: string; + physicalPath: string; + sourceType: "uri" | "path"; +}; + +export function normalizeMediaReferenceSource(source: string): string { + const trimmed = source.trim(); + if (/^media:\/\//i.test(trimmed)) { + return trimmed; + } + return trimmed.replace(/^\s*MEDIA\s*:\s*/i, "").trim(); +} + +export type MediaReferenceSourceInfo = { + hasScheme: boolean; + hasUnsupportedScheme: boolean; + isDataUrl: boolean; + isFileUrl: boolean; + isHttpUrl: boolean; + isMediaStoreUrl: boolean; + looksLikeWindowsDrivePath: boolean; +}; + +export function classifyMediaReferenceSource( + source: string, + options?: { allowDataUrl?: boolean }, +): MediaReferenceSourceInfo { + const allowDataUrl = options?.allowDataUrl ?? true; + const looksLikeWindowsDrivePath = /^[a-zA-Z]:[\\/]/.test(source); + const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(source); + const isFileUrl = /^file:/i.test(source); + const isHttpUrl = /^https?:\/\//i.test(source); + const isDataUrl = /^data:/i.test(source); + const isMediaStoreUrl = /^media:\/\//i.test(source); + const hasUnsupportedScheme = + hasScheme && + !looksLikeWindowsDrivePath && + !isFileUrl && + !isHttpUrl && + !isMediaStoreUrl && + !(allowDataUrl && isDataUrl); + return { + hasScheme, + hasUnsupportedScheme, + isDataUrl, + isFileUrl, + isHttpUrl, + isMediaStoreUrl, + looksLikeWindowsDrivePath, + }; +} + +function maybeLocalPathFromSource(source: string): string | null { + if (/^file:/i.test(source)) { + try { + return safeFileURLToPath(source); + } catch { + return null; + } + } + if (source.startsWith("~")) { + return resolveUserPath(source); + } + if (path.isAbsolute(source)) { + return source; + } + return null; +} + +async function resolveInboundMediaUri( + normalizedSource: string, +): Promise { + if (!/^media:\/\//i.test(normalizedSource)) { + return null; + } + + let parsed: URL; + try { + parsed = new URL(normalizedSource); + } catch (err) { + throw new MediaReferenceError("invalid-path", `Invalid media URI: ${normalizedSource}`, { + cause: err, + }); + } + + if (parsed.hostname !== "inbound") { + throw new MediaReferenceError( + "path-not-allowed", + `Unsupported media URI location: ${parsed.hostname || "(missing)"}`, + ); + } + + let id: string; + try { + id = decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); + } catch (err) { + throw new MediaReferenceError("invalid-path", `Invalid media URI: ${normalizedSource}`, { + cause: err, + }); + } + + if (!id || id.includes("/") || id.includes("\\")) { + throw new MediaReferenceError("invalid-path", `Invalid media URI: ${normalizedSource}`); + } + + return { + id, + normalizedSource, + physicalPath: await resolveInboundMediaPath(id, normalizedSource), + sourceType: "uri", + }; +} + +export async function resolveInboundMediaReference( + source: string, +): Promise { + const normalizedSource = normalizeMediaReferenceSource(source); + if (!normalizedSource) { + return null; + } + + const uriSource = await resolveInboundMediaUri(normalizedSource); + if (uriSource) { + return uriSource; + } + + const localPath = maybeLocalPathFromSource(normalizedSource); + if (!localPath) { + return null; + } + + const inboundDir = path.resolve(getMediaDir(), "inbound"); + const resolvedPath = path.resolve(localPath); + const rel = path.relative(inboundDir, resolvedPath); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel) || rel.includes(path.sep)) { + return null; + } + + return { + id: rel, + normalizedSource, + physicalPath: await resolveInboundMediaPath(rel, normalizedSource), + sourceType: "path", + }; +} + +export async function resolveMediaReferenceLocalPath(source: string): Promise { + const normalizedSource = normalizeMediaReferenceSource(source); + return (await resolveInboundMediaReference(normalizedSource))?.physicalPath ?? normalizedSource; +} + +async function resolveInboundMediaPath(id: string, source: string): Promise { + try { + return await resolveMediaBufferPath(id, "inbound"); + } catch (err) { + throw new MediaReferenceError( + "invalid-path", + err instanceof Error ? err.message : `Invalid media reference: ${source}`, + { cause: err }, + ); + } +} diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts index d6bc60c5609..2a052d3a542 100644 --- a/src/media/web-media.test.ts +++ b/src/media/web-media.test.ts @@ -451,6 +451,26 @@ describe("loadWebMedia", () => { } }); + it("allows managed inbound absolute paths before allowed-root checks", async () => { + const id = `signal-path-${Date.now()}-${Math.random().toString(36).slice(2)}.png`; + const filePath = path.join(stateDir, "media", "inbound", id); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, Buffer.from(TINY_PNG_BASE64, "base64")); + + try { + const result = await loadWebMedia(filePath, { + maxBytes: 1024 * 1024, + localRoots: [], + }); + + expect(result.kind).toBe("image"); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.fileName).toBe(id); + } finally { + await fs.rm(filePath, { force: true }); + } + }); + it("rejects unsupported media store URI locations", async () => { await expect(loadWebMedia("media://outbound/tiny.png")).rejects.toMatchObject({ code: "path-not-allowed", diff --git a/src/media/web-media.ts b/src/media/web-media.ts index 1691faefe5f..de84f3ee7b3 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -19,6 +19,7 @@ import { LocalMediaAccessError, type LocalMediaAccessErrorCode, } from "./local-media-access.js"; +import { MediaReferenceError, resolveInboundMediaReference } from "./media-reference.js"; import { detectMime, extensionForMime, @@ -27,7 +28,6 @@ import { mimeTypeFromFilePath, normalizeMimeType, } from "./mime.js"; -import { resolveMediaBufferPath } from "./store.js"; export { getDefaultLocalRoots, LocalMediaAccessError }; export type { LocalMediaAccessErrorCode }; @@ -58,42 +58,16 @@ type WebMediaOptions = { }; async function resolveMediaStoreUriToPath(mediaUrl: string): Promise { - if (!mediaUrl.startsWith("media://")) { + if (!/^media:\/\//i.test(mediaUrl)) { return null; } - let parsed: URL; try { - parsed = new URL(mediaUrl); + return (await resolveInboundMediaReference(mediaUrl))?.physicalPath ?? null; } catch (err) { - throw new LocalMediaAccessError("invalid-path", `Invalid media URI: ${mediaUrl}`, { - cause: err, - }); - } - if (parsed.hostname !== "inbound") { - throw new LocalMediaAccessError( - "path-not-allowed", - `Unsupported media URI location: ${parsed.hostname || "(missing)"}`, - ); - } - let id: string; - try { - id = decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); - } catch (err) { - throw new LocalMediaAccessError("invalid-path", `Invalid media URI: ${mediaUrl}`, { - cause: err, - }); - } - if (!id || id.includes("/")) { - throw new LocalMediaAccessError("invalid-path", `Invalid media URI: ${mediaUrl}`); - } - try { - return await resolveMediaBufferPath(id, "inbound"); - } catch (err) { - throw new LocalMediaAccessError( - "invalid-path", - err instanceof Error ? err.message : `Invalid media URI: ${mediaUrl}`, - { cause: err }, - ); + if (err instanceof MediaReferenceError) { + throw new LocalMediaAccessError(err.code, err.message, { cause: err }); + } + throw err; } }