fix(media): distrust image hints for container bytes · openclaw/openclaw@306ca4d
vincentkoc
·
2026-05-16
·
via Recent Commits to openclaw:main
| Original file line number | Diff line number | Diff line change |
|---|
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
17 | 17 | ### Fixes |
18 | 18 | |
19 | 19 | - 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. |
| 20 | +- 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. |
20 | 21 | - Update/doctor: avoid materializing `groupAllowFrom` for channel schemas that reject it, so package-swap doctor repairs do not fail on externalized Slack configs. |
21 | 22 | - 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. |
22 | 23 | - 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. |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -78,6 +78,30 @@ describe("mime detection", () => {
|
78 | 78 | }, |
79 | 79 | expected: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", |
80 | 80 | }, |
| 81 | +{ |
| 82 | +name: "does not let image extensions override generic zip bytes", |
| 83 | +input: async () => { |
| 84 | +const zip = new JSZip(); |
| 85 | +zip.file("hello.txt", "hi"); |
| 86 | +return { |
| 87 | +buffer: await zip.generateAsync({ type: "nodebuffer" }), |
| 88 | +filePath: "/tmp/fake.png", |
| 89 | +}; |
| 90 | +}, |
| 91 | +expected: "application/zip", |
| 92 | +}, |
| 93 | +{ |
| 94 | +name: "does not let image headers override generic zip bytes", |
| 95 | +input: async () => { |
| 96 | +const zip = new JSZip(); |
| 97 | +zip.file("hello.txt", "hi"); |
| 98 | +return { |
| 99 | +buffer: await zip.generateAsync({ type: "nodebuffer" }), |
| 100 | +headerMime: "image/png", |
| 101 | +}; |
| 102 | +}, |
| 103 | +expected: "application/zip", |
| 104 | +}, |
81 | 105 | { |
82 | 106 | name: "uses extension mapping for JavaScript assets", |
83 | 107 | input: async () => ({ |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -190,6 +190,10 @@ function isGenericMime(mime?: string): boolean {
|
190 | 190 | return m === "application/octet-stream" || m === "application/zip"; |
191 | 191 | } |
192 | 192 | |
| 193 | +function isImageMime(mime?: string): boolean { |
| 194 | +return mediaKindFromMime(normalizeMimeType(mime)) === "image"; |
| 195 | +} |
| 196 | + |
193 | 197 | async function detectMimeImpl(opts: { |
194 | 198 | buffer?: Buffer; |
195 | 199 | headerMime?: string | null; |
@@ -200,23 +204,27 @@ async function detectMimeImpl(opts: {
|
200 | 204 | |
201 | 205 | const headerMime = normalizeMimeType(opts.headerMime); |
202 | 206 | const sniffed = await sniffMime(opts.buffer); |
| 207 | +const sniffedGenericContainer = sniffed && isGenericMime(sniffed); |
| 208 | +const trustedExtMime = sniffedGenericContainer && isImageMime(extMime) ? undefined : extMime; |
| 209 | +const trustedHeaderMime = |
| 210 | +sniffedGenericContainer && isImageMime(headerMime) ? undefined : headerMime; |
203 | 211 | |
204 | 212 | // Prefer sniffed types, but don't let generic container types override a more |
205 | 213 | // specific extension mapping (e.g. XLSX vs ZIP). |
206 | | -if (sniffed && (!isGenericMime(sniffed) || !extMime)) { |
| 214 | +if (sniffed && (!isGenericMime(sniffed) || !trustedExtMime)) { |
207 | 215 | return sniffed; |
208 | 216 | } |
209 | | -if (extMime) { |
210 | | -return extMime; |
| 217 | +if (trustedExtMime) { |
| 218 | +return trustedExtMime; |
211 | 219 | } |
212 | | -if (headerMime && !isGenericMime(headerMime)) { |
213 | | -return headerMime; |
| 220 | +if (trustedHeaderMime && !isGenericMime(trustedHeaderMime)) { |
| 221 | +return trustedHeaderMime; |
214 | 222 | } |
215 | 223 | if (sniffed) { |
216 | 224 | return sniffed; |
217 | 225 | } |
218 | | -if (headerMime) { |
219 | | -return headerMime; |
| 226 | +if (trustedHeaderMime) { |
| 227 | +return trustedHeaderMime; |
220 | 228 | } |
221 | 229 | |
222 | 230 | return undefined; |
|
| Original file line number | Diff line number | Diff line change |
|---|
|
1 | 1 | import fs from "node:fs/promises"; |
2 | 2 | import path from "node:path"; |
3 | 3 | import { pathToFileURL } from "node:url"; |
| 4 | +import JSZip from "jszip"; |
4 | 5 | import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; |
5 | 6 | import { resolveStateDir } from "../config/paths.js"; |
6 | 7 | import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; |
@@ -308,6 +309,19 @@ describe("loadWebMedia", () => {
|
308 | 309 | expect(result.buffer.length).toBeGreaterThan(0); |
309 | 310 | }); |
310 | 311 | |
| 312 | +it("does not treat image-named generic container bytes as local image media", async () => { |
| 313 | +const zip = new JSZip(); |
| 314 | +zip.file("hello.txt", "hi"); |
| 315 | +const fakeImage = path.join(fixtureRoot, "fake.png"); |
| 316 | +await fs.writeFile(fakeImage, await zip.generateAsync({ type: "nodebuffer" })); |
| 317 | + |
| 318 | +const result = await loadWebMedia(fakeImage, createLocalWebMediaOptions()); |
| 319 | + |
| 320 | +expect(result.kind).toBe("document"); |
| 321 | +expect(result.contentType).toBe("application/zip"); |
| 322 | +expect(result.fileName).toBe("fake.png"); |
| 323 | +}); |
| 324 | + |
311 | 325 | it("uses only the leaf filename from Windows-style sandbox-validated media paths", async () => { |
312 | 326 | const result = await loadWebMedia(String.raw`C:\workspace\captures\tiny.png`, { |
313 | 327 | maxBytes: 1024 * 1024, |
|
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。