From 2bdbf240a92de26a46dd176acdb85f0c4e7e7bc3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 16 May 2026 14:09:29 +0800 Subject: [PATCH] fix(media): avoid staged image extensions for containers --- CHANGELOG.md | 2 +- src/media/store.test.ts | 15 +++++++++++++ src/media/store.ts | 47 +++++++++++++++++++++++++++++++++++------ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a099924b8f..b83d71f1659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes - MCP plugin tools: forward host MCP `tools/call` `AbortSignal` through `createPluginToolsMcpHandlers().callTool` into plugin `tool.execute`, so host cancellation actually cancels in-flight plugin tool calls instead of letting them run to completion. (#82443) Thanks @joshavant. -- Media: ignore image MIME and filename hints when bytes sniff as generic containers, so zip/octet-stream payloads mislabeled as images do not become local image media. +- Media: ignore image MIME and filename hints when bytes sniff as generic containers, so zip/octet-stream payloads mislabeled as images do not become local image media or keep image file extensions when staged. - Update/doctor: avoid materializing `groupAllowFrom` for channel schemas that reject it, so package-swap doctor repairs do not fail on externalized Slack configs. - Gateway/media: prevent image filenames from overriding generic non-image byte sniffing, so zip/octet-stream payloads mislabeled as images are offloaded or rejected before they become inline image attachments. - Plugins/web search: downgrade stale optional provider installs to warnings so Gateway and doctor repair paths keep running after startup provider selection. Refs #82313. Thanks @crackmac. diff --git a/src/media/store.test.ts b/src/media/store.test.ts index 6aa029eaeea..a141cabcaea 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -670,6 +670,21 @@ describe("media store", () => { expectedContentType: "application/octet-stream", expectedExtension: ".custom", }, + { + name: "does not preserve image header extensions for generic container buffers", + bufferFactory: async () => { + const zip = new JSZip(); + zip.file("hello.txt", "hi"); + return await zip.generateAsync({ type: "nodebuffer" }); + }, + contentType: "image/png", + originalFilename: "fake.png", + expectedContentType: "application/zip", + expectedExtension: ".zip", + assertSaved: async (saved: Awaited>) => { + expect(path.basename(saved.path)).toMatch(/^fake---[a-f0-9-]{36}\.zip$/); + }, + }, ] as const)("$name", async (testCase) => { const buffer = "bufferFactory" in testCase && testCase.bufferFactory diff --git a/src/media/store.ts b/src/media/store.ts index 7611c61d576..55a9d3f5387 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -329,6 +329,34 @@ function extensionForAuthoritativeHeaderMime(contentType?: string): string | und return extensionForMime(mime); } +function isGenericContainerMime(mime?: string): boolean { + return mime === "application/zip" || mime === "application/octet-stream"; +} + +function isImageHeaderMime(contentType?: string): boolean { + return normalizeOptionalString(contentType?.split(";")[0])?.startsWith("image/") === true; +} + +function resolveSavedMediaExtension(params: { + detectedMime?: string; + headerExt?: string; + contentType?: string; + originalFilename?: string; +}): string { + const trustedHeaderExt = + params.headerExt && + isGenericContainerMime(params.detectedMime) && + isImageHeaderMime(params.contentType) + ? undefined + : params.headerExt; + return ( + trustedHeaderExt ?? + extensionForMime(params.detectedMime) ?? + safeOriginalFilenameExtension(params.originalFilename) ?? + "" + ); +} + function buildSavedMediaResult(params: { dir: string; id: string; @@ -532,8 +560,12 @@ export async function saveMediaBuffer( headerMime: contentType, filePath: originalFilename ?? detectionFilePathHint, }); - const ext = - headerExt ?? extensionForMime(mime) ?? safeOriginalFilenameExtension(originalFilename) ?? ""; + const ext = resolveSavedMediaExtension({ + detectedMime: mime, + headerExt, + contentType, + originalFilename, + }); const id = buildSavedMediaId({ baseId: uuid, ext, originalFilename }); await writeSavedMediaBuffer({ subdir, id, buffer }); return buildSavedMediaResult({ dir, id, size: buffer.byteLength, contentType: mime }); @@ -567,11 +599,12 @@ export async function saveMediaStream( headerMime: contentType, filePath: originalFilename ?? detectionFilePathHint, }); - const ext = - headerExt ?? - extensionForMime(mime) ?? - safeOriginalFilenameExtension(originalFilename) ?? - ""; + const ext = resolveSavedMediaExtension({ + detectedMime: mime, + headerExt, + contentType, + originalFilename, + }); const id = buildSavedMediaId({ baseId, ext, originalFilename }); return { id, size, contentType: mime }; },