fix(media): distrust image hints for container bytes

This commit is contained in:
Vincent Koc
2026-05-16 14:03:45 +08:00
parent 3bc7d4061b
commit 306ca4d3eb
4 changed files with 54 additions and 7 deletions

View File

@@ -17,6 +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.
- 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

@@ -78,6 +78,30 @@ describe("mime detection", () => {
},
expected: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
{
name: "does not let image extensions override generic zip bytes",
input: async () => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
return {
buffer: await zip.generateAsync({ type: "nodebuffer" }),
filePath: "/tmp/fake.png",
};
},
expected: "application/zip",
},
{
name: "does not let image headers override generic zip bytes",
input: async () => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
return {
buffer: await zip.generateAsync({ type: "nodebuffer" }),
headerMime: "image/png",
};
},
expected: "application/zip",
},
{
name: "uses extension mapping for JavaScript assets",
input: async () => ({

View File

@@ -190,6 +190,10 @@ function isGenericMime(mime?: string): boolean {
return m === "application/octet-stream" || m === "application/zip";
}
function isImageMime(mime?: string): boolean {
return mediaKindFromMime(normalizeMimeType(mime)) === "image";
}
async function detectMimeImpl(opts: {
buffer?: Buffer;
headerMime?: string | null;
@@ -200,23 +204,27 @@ async function detectMimeImpl(opts: {
const headerMime = normalizeMimeType(opts.headerMime);
const sniffed = await sniffMime(opts.buffer);
const sniffedGenericContainer = sniffed && isGenericMime(sniffed);
const trustedExtMime = sniffedGenericContainer && isImageMime(extMime) ? undefined : extMime;
const trustedHeaderMime =
sniffedGenericContainer && isImageMime(headerMime) ? undefined : headerMime;
// Prefer sniffed types, but don't let generic container types override a more
// specific extension mapping (e.g. XLSX vs ZIP).
if (sniffed && (!isGenericMime(sniffed) || !extMime)) {
if (sniffed && (!isGenericMime(sniffed) || !trustedExtMime)) {
return sniffed;
}
if (extMime) {
return extMime;
if (trustedExtMime) {
return trustedExtMime;
}
if (headerMime && !isGenericMime(headerMime)) {
return headerMime;
if (trustedHeaderMime && !isGenericMime(trustedHeaderMime)) {
return trustedHeaderMime;
}
if (sniffed) {
return sniffed;
}
if (headerMime) {
return headerMime;
if (trustedHeaderMime) {
return trustedHeaderMime;
}
return undefined;

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import JSZip from "jszip";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { resolveStateDir } from "../config/paths.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
@@ -308,6 +309,19 @@ describe("loadWebMedia", () => {
expect(result.buffer.length).toBeGreaterThan(0);
});
it("does not treat image-named generic container bytes as local image media", async () => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
const fakeImage = path.join(fixtureRoot, "fake.png");
await fs.writeFile(fakeImage, await zip.generateAsync({ type: "nodebuffer" }));
const result = await loadWebMedia(fakeImage, createLocalWebMediaOptions());
expect(result.kind).toBe("document");
expect(result.contentType).toBe("application/zip");
expect(result.fileName).toBe("fake.png");
});
it("uses only the leaf filename from Windows-style sandbox-validated media paths", async () => {
const result = await loadWebMedia(String.raw`C:\workspace\captures\tiny.png`, {
maxBytes: 1024 * 1024,