mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix(media): centralize inbound media reference resolution
This commit is contained in:
36
src/media/local-media-access.test.ts
Normal file
36
src/media/local-media-access.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
142
src/media/media-reference.test.ts
Normal file
142
src/media/media-reference.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
181
src/media/media-reference.ts
Normal file
181
src/media/media-reference.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user