Skip to content

Commit 9fa8b86

Browse files
authored
fix: harden image metadata fallback (#83579)
1 parent fd8877b commit 9fa8b86

4 files changed

Lines changed: 101 additions & 115 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
4040

4141
### Fixes
4242

43+
- Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.
4344
- Media: install Sharp with the root package and fall back to sips, Windows native imaging, ImageMagick, GraphicsMagick, or ffmpeg for image resizing/conversion when Sharp is unavailable. Fixes #83401. Thanks @scotthuang.
4445
- Telegram: deliver generated media completions back into forum topics by preserving topic IDs across requester-agent handoff. (#83556) Thanks @fuller-stack-dev.
4546
- Gateway: defer update-check startup until after readiness so package update checks no longer block sidecar-ready startup, while preserving update broadcasts and shutdown cleanup. (#83520) Thanks @samzong.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { createPngBufferWithDimensions } from "./test-helpers.js";
3+
4+
const { loadBundledPluginPublicArtifactModuleSyncMock, resolveSystemBinMock, runExecMock } =
5+
vi.hoisted(() => ({
6+
loadBundledPluginPublicArtifactModuleSyncMock: vi.fn(),
7+
resolveSystemBinMock: vi.fn(),
8+
runExecMock: vi.fn(),
9+
}));
10+
11+
vi.mock("../plugins/public-surface-loader.js", () => ({
12+
loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock,
13+
}));
14+
15+
vi.mock("../infra/resolve-system-bin.js", () => ({
16+
resolveSystemBin: resolveSystemBinMock,
17+
}));
18+
19+
vi.mock("../process/exec.js", () => ({
20+
runExec: runExecMock,
21+
}));
22+
23+
import { getImageMetadata, resizeToJpeg } from "./image-ops.js";
24+
25+
describe("image ops external backend security", () => {
26+
const previousBackend = process.env.OPENCLAW_IMAGE_BACKEND;
27+
28+
afterEach(() => {
29+
if (previousBackend === undefined) {
30+
delete process.env.OPENCLAW_IMAGE_BACKEND;
31+
} else {
32+
process.env.OPENCLAW_IMAGE_BACKEND = previousBackend;
33+
}
34+
loadBundledPluginPublicArtifactModuleSyncMock.mockReset();
35+
resolveSystemBinMock.mockReset();
36+
runExecMock.mockReset();
37+
});
38+
39+
it("does not use external metadata tools for unrecognized image bytes", async () => {
40+
process.env.OPENCLAW_IMAGE_BACKEND = "imagemagick";
41+
resolveSystemBinMock.mockReturnValue("/usr/bin/magick");
42+
43+
const svgWithExternalReference = Buffer.from(
44+
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><image href="http://127.0.0.1:9/probe.png" width="1" height="1"/></svg>',
45+
);
46+
47+
await expect(getImageMetadata(svgWithExternalReference)).resolves.toBeNull();
48+
49+
expect(runExecMock).not.toHaveBeenCalled();
50+
expect(loadBundledPluginPublicArtifactModuleSyncMock).not.toHaveBeenCalled();
51+
});
52+
53+
it("stops backend fallback after a real processing error", async () => {
54+
delete process.env.OPENCLAW_IMAGE_BACKEND;
55+
resolveSystemBinMock.mockReturnValue("/usr/bin/magick");
56+
loadBundledPluginPublicArtifactModuleSyncMock.mockReturnValue({
57+
createMediaAttachmentImageOps: () => ({
58+
getImageMetadata: vi.fn(),
59+
normalizeExifOrientation: vi.fn(),
60+
resizeToJpeg: vi.fn(async () => {
61+
throw new Error("corrupt image payload");
62+
}),
63+
convertHeicToJpeg: vi.fn(),
64+
hasAlphaChannel: vi.fn(),
65+
resizeToPng: vi.fn(),
66+
}),
67+
});
68+
69+
await expect(
70+
resizeToJpeg({
71+
buffer: createPngBufferWithDimensions({ width: 1, height: 1 }),
72+
maxSide: 1,
73+
quality: 80,
74+
}),
75+
).rejects.toThrow(/corrupt image payload/);
76+
77+
expect(runExecMock).not.toHaveBeenCalled();
78+
});
79+
});

src/media/image-ops.tempdir.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import fs from "node:fs/promises";
22
import path from "node:path";
33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
5-
import { getImageMetadata } from "./image-ops.js";
5+
import { resizeToJpeg } from "./image-ops.js";
6+
7+
const PNG_1X1_BASE64 =
8+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
69

710
describe("image-ops temp dir", () => {
811
let createdTempDir = "";
@@ -26,7 +29,11 @@ describe("image-ops temp dir", () => {
2629
async () => {
2730
const secureRoot = await fs.realpath(resolvePreferredOpenClawTmpDir());
2831

29-
await getImageMetadata(Buffer.from("image"));
32+
await resizeToJpeg({
33+
buffer: Buffer.from(PNG_1X1_BASE64, "base64"),
34+
maxSide: 1,
35+
quality: 80,
36+
});
3037

3138
expect(fs.mkdtemp).toHaveBeenCalledTimes(1);
3239
const [mkdtempCall] = vi.mocked(fs.mkdtemp).mock.calls;

src/media/image-ops.ts

Lines changed: 12 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,7 @@ type ExternalImageTool =
6161
export const IMAGE_REDUCE_QUALITY_STEPS = [85, 75, 65, 55, 45, 35] as const;
6262
export const MAX_IMAGE_INPUT_PIXELS = 25_000_000;
6363
const IMAGE_PROCESS_TIMEOUT_MS = 20_000;
64-
const IMAGE_METADATA_TIMEOUT_MS = 10_000;
6564
const IMAGE_TOOL_MAX_BUFFER = 1024 * 1024;
66-
const IMAGE_METADATA_MAX_BUFFER = 512 * 1024;
6765
const MEDIA_UNDERSTANDING_CORE_PLUGIN_ID = "media-understanding-core";
6866
const MEDIA_UNDERSTANDING_CORE_IMAGE_OPS_ARTIFACT = "image-ops.js";
6967

@@ -207,6 +205,7 @@ function isImageBackendUnavailableCause(error: unknown): boolean {
207205
detail.includes('cannot find package "sharp"') ||
208206
detail.includes("cannot find module 'sharp'") ||
209207
detail.includes('cannot find module "sharp"') ||
208+
detail.includes("support for this compression format has not been built in") ||
210209
detail.includes("is not available") ||
211210
detail.includes("command not found") ||
212211
detail.includes("enoent")
@@ -223,12 +222,11 @@ async function runWithImageBackends<T>(
223222
return await fn(backend);
224223
} catch (error) {
225224
errors.push(error);
225+
if (!isImageBackendUnavailableCause(error)) {
226+
throw error;
227+
}
226228
}
227229
}
228-
const processingError = errors.find((error) => !isImageBackendUnavailableCause(error));
229-
if (processingError) {
230-
throw processingError;
231-
}
232230
throw createImageProcessorUnavailableError(operation, errors);
233231
}
234232

@@ -768,18 +766,6 @@ async function runPowerShellImageScript(
768766
});
769767
}
770768

771-
const WINDOWS_NATIVE_METADATA_SCRIPT = `
772-
param([string]$InputPath)
773-
$ErrorActionPreference = 'Stop'
774-
Add-Type -AssemblyName System.Drawing
775-
$image = [System.Drawing.Image]::FromFile($InputPath)
776-
try {
777-
[Console]::Out.WriteLine(('{0} {1}' -f $image.Width, $image.Height))
778-
} finally {
779-
$image.Dispose()
780-
}
781-
`;
782-
783769
const WINDOWS_NATIVE_RESIZE_SCRIPT = `
784770
param(
785771
[string]$InputPath,
@@ -858,22 +844,6 @@ try {
858844
}
859845
`;
860846

861-
async function windowsNativeMetadataFromBuffer(buffer: Buffer): Promise<ImageMetadata | null> {
862-
return await withImageTemp(async (workspace) => {
863-
const input = await workspace.write("in.img", buffer);
864-
const { stdout } = await runPowerShellImageScript(
865-
"metadata.ps1",
866-
WINDOWS_NATIVE_METADATA_SCRIPT,
867-
[input],
868-
);
869-
const [widthRaw, heightRaw] = stdout.trim().split(/\s+/, 2);
870-
return buildImageMetadata(
871-
Number.parseInt(widthRaw ?? "", 10),
872-
Number.parseInt(heightRaw ?? "", 10),
873-
);
874-
});
875-
}
876-
877847
async function windowsNativeResize(
878848
params: ResizeToJpegParams | ResizeToPngParams,
879849
format: "jpeg" | "png",
@@ -904,51 +874,6 @@ async function runConvertTool(
904874
});
905875
}
906876

907-
async function metadataFromIdentifyTool(
908-
tool: Extract<ExternalImageTool, { flavor: "magick" | "convert" | "gm" }>,
909-
buffer: Buffer,
910-
): Promise<ImageMetadata | null> {
911-
return await withImageTemp(async (workspace) => {
912-
const input = await workspace.write("in.img", buffer);
913-
const command =
914-
tool.flavor === "convert"
915-
? resolveSystemBin("identify", { trust: "standard" })
916-
: tool.command;
917-
if (!command) {
918-
return null;
919-
}
920-
const args = tool.flavor === "magick" ? ["identify"] : tool.flavor === "gm" ? ["identify"] : [];
921-
const { stdout } = await runExec(command, [...args, "-format", "%w %h", input], {
922-
timeoutMs: IMAGE_METADATA_TIMEOUT_MS,
923-
maxBuffer: IMAGE_METADATA_MAX_BUFFER,
924-
});
925-
const [widthRaw, heightRaw] = stdout.trim().split(/\s+/, 2);
926-
const width = Number.parseInt(widthRaw ?? "", 10);
927-
const height = Number.parseInt(heightRaw ?? "", 10);
928-
return buildImageMetadata(width, height);
929-
});
930-
}
931-
932-
async function externalMetadataFromBuffer(
933-
backend: Exclude<ImageBackend, "sharp">,
934-
buffer: Buffer,
935-
): Promise<ImageMetadata | null> {
936-
const tool = resolveImageTool(backend);
937-
if (!tool) {
938-
throw new Error(`Image backend ${backend} is not available`);
939-
}
940-
if (tool.flavor === "sips") {
941-
return await sipsMetadataFromBuffer(buffer);
942-
}
943-
if (tool.flavor === "powershell") {
944-
return await windowsNativeMetadataFromBuffer(buffer);
945-
}
946-
if (tool.flavor === "ffmpeg") {
947-
return null;
948-
}
949-
return await metadataFromIdentifyTool(tool, buffer);
950-
}
951-
952877
function buildResizeGeometry(maxSide: number, withoutEnlargement?: boolean): string {
953878
const side = clampInteger(maxSide, 1, Number.MAX_SAFE_INTEGER);
954879
return `${side}x${side}${withoutEnlargement === false ? "" : ">"}`;
@@ -1114,34 +1039,6 @@ async function externalResizeToPng(
11141039
});
11151040
}
11161041

1117-
async function sipsMetadataFromBuffer(buffer: Buffer): Promise<ImageMetadata | null> {
1118-
return await withImageTemp(async (workspace) => {
1119-
const input = await workspace.write("in.img", buffer);
1120-
const { stdout } = await runExec(
1121-
"/usr/bin/sips",
1122-
["-g", "pixelWidth", "-g", "pixelHeight", input],
1123-
{
1124-
timeoutMs: IMAGE_METADATA_TIMEOUT_MS,
1125-
maxBuffer: IMAGE_METADATA_MAX_BUFFER,
1126-
},
1127-
);
1128-
const w = stdout.match(/pixelWidth:\s*([0-9]+)/);
1129-
const h = stdout.match(/pixelHeight:\s*([0-9]+)/);
1130-
if (!w?.[1] || !h?.[1]) {
1131-
return null;
1132-
}
1133-
const width = Number.parseInt(w[1], 10);
1134-
const height = Number.parseInt(h[1], 10);
1135-
if (!Number.isFinite(width) || !Number.isFinite(height)) {
1136-
return null;
1137-
}
1138-
if (width <= 0 || height <= 0) {
1139-
return null;
1140-
}
1141-
return { width, height };
1142-
});
1143-
}
1144-
11451042
async function sipsResizeToJpeg(params: {
11461043
buffer: Buffer;
11471044
maxSide: number;
@@ -1193,13 +1090,15 @@ export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata |
11931090
}
11941091
}
11951092

1196-
return await runWithImageBackends("metadata", async (backend) => {
1197-
const meta =
1198-
backend === "sharp"
1199-
? await (await loadMediaAttachmentImageOps()).getImageMetadata(buffer)
1200-
: await externalMetadataFromBuffer(backend, buffer);
1093+
const preference = getImageBackendPreference();
1094+
if (preference !== "auto" && preference !== "sharp") {
1095+
return null;
1096+
}
1097+
1098+
return await (async () => {
1099+
const meta = await (await loadMediaAttachmentImageOps()).getImageMetadata(buffer);
12011100
return meta ? validateImagePixelLimit(meta) : null;
1202-
}).catch(() => null);
1101+
})().catch(() => null);
12031102
}
12041103

12051104
/**

0 commit comments

Comments
 (0)