fix(media): centralize inbound media reference resolution

This commit is contained in:
Peter Steinberger
2026-04-25 00:48:48 +01:00
parent aa27e27f36
commit 4e9c83d4d8
6 changed files with 391 additions and 33 deletions

View File

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

View File

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

View File

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

View File

@@ -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<InboundMediaReference | null> {
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<InboundMediaReference | null> {
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<string> {
const normalizedSource = normalizeMediaReferenceSource(source);
return (await resolveInboundMediaReference(normalizedSource))?.physicalPath ?? normalizedSource;
}
async function resolveInboundMediaPath(id: string, source: string): Promise<string> {
try {
return await resolveMediaBufferPath(id, "inbound");
} catch (err) {
throw new MediaReferenceError(
"invalid-path",
err instanceof Error ? err.message : `Invalid media reference: ${source}`,
{ cause: err },
);
}
}

View File

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

View File

@@ -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<string | null> {
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;
}
}