fix(media): avoid staged image extensions for containers

This commit is contained in:
Vincent Koc
2026-05-16 14:09:29 +08:00
parent 6920ec6c54
commit 2bdbf240a9
3 changed files with 56 additions and 8 deletions

View File

@@ -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.

View File

@@ -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<ReturnType<typeof store.saveMediaBuffer>>) => {
expect(path.basename(saved.path)).toMatch(/^fake---[a-f0-9-]{36}\.zip$/);
},
},
] as const)("$name", async (testCase) => {
const buffer =
"bufferFactory" in testCase && testCase.bufferFactory

View File

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