From 306ca4d3ebd3ce6e2ade611e5997d1b9febffa34 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 16 May 2026 14:03:45 +0800 Subject: [PATCH] fix(media): distrust image hints for container bytes --- CHANGELOG.md | 1 + src/media/mime.test.ts | 24 ++++++++++++++++++++++++ src/media/mime.ts | 22 +++++++++++++++------- src/media/web-media.test.ts | 14 ++++++++++++++ 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e626b4861c0..d202859d41c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/media/mime.test.ts b/src/media/mime.test.ts index c6b852ff035..582b02f21cf 100644 --- a/src/media/mime.test.ts +++ b/src/media/mime.test.ts @@ -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 () => ({ diff --git a/src/media/mime.ts b/src/media/mime.ts index 99c3ad15677..28364b185b8 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -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; diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts index e9ef9826feb..fdf4bde2198 100644 --- a/src/media/web-media.test.ts +++ b/src/media/web-media.test.ts @@ -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,