diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f6e09fe89..9b8cb0cde4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -234,6 +234,7 @@ Docs: https://docs.openclaw.ai - Plugin runtime/events: expose `runtime.events.onAgentEvent` and `runtime.events.onSessionTranscriptUpdate` for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic. - CLI/Banner taglines: add `cli.banner.taglineMode` (`random` | `default` | `off`) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior. - Agents/compaction safeguard quality-audit rollout: keep summary quality audits disabled by default unless `agents.defaults.compaction.qualityGuard` is explicitly enabled, and add config plumbing for bounded retry control. (#25556) thanks @rodrigouroz. +- Gateway/input_image MIME validation: sniff uploaded image bytes before MIME allowlist enforcement again so declared image types cannot mask concrete non-image payloads, while keeping HEIC/HEIF normalization behavior scoped to actual HEIC inputs. Thanks @vincentkoc. ### Breaking diff --git a/src/media/input-files.fetch-guard.test.ts b/src/media/input-files.fetch-guard.test.ts index 05d59d37e76..377bbf78fa9 100644 --- a/src/media/input-files.fetch-guard.test.ts +++ b/src/media/input-files.fetch-guard.test.ts @@ -99,7 +99,9 @@ describe("HEIC input image normalization", () => { expect(release).toHaveBeenCalledTimes(1); }); - it("keeps declared MIME for non-HEIC images without sniffing", async () => { + it("keeps declared MIME for non-HEIC images after validation", async () => { + detectMimeMock.mockResolvedValueOnce("image/png"); + const image = await extractImageContentFromSource( { type: "base64", @@ -115,7 +117,7 @@ describe("HEIC input image normalization", () => { }, ); - expect(detectMimeMock).not.toHaveBeenCalled(); + expect(detectMimeMock).toHaveBeenCalledTimes(1); expect(convertHeicToJpegMock).not.toHaveBeenCalled(); expect(image).toEqual({ type: "image", @@ -123,6 +125,59 @@ describe("HEIC input image normalization", () => { mimeType: "image/png", }); }); + + it("rejects spoofed base64 images when detected bytes are not an image", async () => { + detectMimeMock.mockResolvedValueOnce("application/pdf"); + + await expect( + extractImageContentFromSource( + { + type: "base64", + data: Buffer.from("%PDF-1.4\n").toString("base64"), + mediaType: "image/png", + }, + { + allowUrl: false, + allowedMimes: new Set(["image/png", "image/jpeg"]), + maxBytes: 1024 * 1024, + maxRedirects: 0, + timeoutMs: 1, + }, + ), + ).rejects.toThrow("Unsupported image MIME type: application/pdf"); + expect(convertHeicToJpegMock).not.toHaveBeenCalled(); + }); + + it("rejects spoofed URL images when detected bytes are not an image", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(Buffer.from("%PDF-1.4\n"), { + status: 200, + headers: { "content-type": "image/png" }, + }), + release, + finalUrl: "https://example.com/photo.png", + }); + detectMimeMock.mockResolvedValueOnce("application/pdf"); + + await expect( + extractImageContentFromSource( + { + type: "url", + url: "https://example.com/photo.png", + }, + { + allowUrl: true, + allowedMimes: new Set(["image/png", "image/jpeg"]), + maxBytes: 1024 * 1024, + maxRedirects: 0, + timeoutMs: 1000, + }, + ), + ).rejects.toThrow("Unsupported image MIME type: application/pdf"); + expect(release).toHaveBeenCalledTimes(1); + expect(convertHeicToJpegMock).not.toHaveBeenCalled(); + }); }); describe("fetchWithGuard", () => { diff --git a/src/media/input-files.ts b/src/media/input-files.ts index b894c6d13b2..32c5998bbd9 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -235,11 +235,17 @@ async function normalizeInputImage(params: { limits: InputImageLimits; }): Promise { const declaredMime = normalizeMimeType(params.mimeType) ?? "application/octet-stream"; - const sourceMime = HEIC_INPUT_IMAGE_MIMES.has(declaredMime) - ? (normalizeMimeType( - await detectMime({ buffer: params.buffer, headerMime: params.mimeType }), - ) ?? declaredMime) - : declaredMime; + const detectedMime = normalizeMimeType( + await detectMime({ buffer: params.buffer, headerMime: params.mimeType }), + ); + if (declaredMime.startsWith("image/") && detectedMime && !detectedMime.startsWith("image/")) { + throw new Error(`Unsupported image MIME type: ${detectedMime}`); + } + const sourceMime = + (detectedMime && HEIC_INPUT_IMAGE_MIMES.has(detectedMime)) || + (HEIC_INPUT_IMAGE_MIMES.has(declaredMime) && !detectedMime) + ? (detectedMime ?? declaredMime) + : declaredMime; if (!params.limits.allowedMimes.has(sourceMime)) { throw new Error(`Unsupported image MIME type: ${sourceMime}`); }