From bde1bad2c1798afdaaacabfdd3ec42feff4537cd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 27 May 2026 20:40:43 +0200 Subject: [PATCH] fix(gateway): bound webchat image data scans --- .../server-methods/chat-webchat-media.test.ts | 23 +++++++++++ .../server-methods/chat-webchat-media.ts | 41 +++++++++++++++---- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/gateway/server-methods/chat-webchat-media.test.ts b/src/gateway/server-methods/chat-webchat-media.test.ts index a7b97015b20..180a8cfdc94 100644 --- a/src/gateway/server-methods/chat-webchat-media.test.ts +++ b/src/gateway/server-methods/chat-webchat-media.test.ts @@ -297,6 +297,29 @@ describe("buildWebchatAssistantMessageFromReplyPayloads", () => { expect(message).toBeNull(); }); + it("keeps valid whitespace in data image payloads", async () => { + const imageUrl = "data:image/png;base64,cG 5n\n"; + const message = await buildWebchatAssistantMessageFromReplyPayloads([ + { + text: "whitespace image", + mediaUrl: imageUrl, + }, + ]); + + expect(message?.content).toContainEqual({ type: "input_image", image_url: imageUrl.trim() }); + }); + + it("rejects invalid data image payload characters", async () => { + const message = await buildWebchatAssistantMessageFromReplyPayloads([ + { + text: "invalid", + mediaUrl: "data:image/png;base64,cG5n!", + }, + ]); + + expect(message).toBeNull(); + }); + it("rejects remote image URLs", async () => { const message = await buildWebchatAssistantMessageFromReplyPayloads([ { diff --git a/src/gateway/server-methods/chat-webchat-media.ts b/src/gateway/server-methods/chat-webchat-media.ts index 052807dabd6..33bc373bfd6 100644 --- a/src/gateway/server-methods/chat-webchat-media.ts +++ b/src/gateway/server-methods/chat-webchat-media.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import { openLocalFileSafely } from "../../infra/fs-safe.js"; import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../../infra/local-file-access.js"; +import { estimateBase64DecodedBytes } from "../../media/base64.js"; import { assertLocalMediaAllowed, LocalMediaAccessError } from "../../media/local-media-access.js"; import { isAudioFileName } from "../../media/mime.js"; import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; @@ -131,10 +132,31 @@ function mimeTypeForPath(filePath: string): string { return MIME_BY_EXT[ext] ?? "audio/mpeg"; } -function estimateBase64DecodedBytes(base64: string): number { - const sanitized = base64.replace(/\s+/g, ""); - const padding = sanitized.endsWith("==") ? 2 : sanitized.endsWith("=") ? 1 : 0; - return Math.floor((sanitized.length * 3) / 4) - padding; +function isBase64DataPayload(value: string): boolean { + if (value.length === 0) { + return false; + } + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + const isBase64Char = + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) || + (code >= 0x30 && code <= 0x39) || + code === 0x2b || + code === 0x2f || + code === 0x3d; + const isWhitespace = + code === 0x09 || + code === 0x0a || + code === 0x0b || + code === 0x0c || + code === 0x0d || + code === 0x20; + if (!isBase64Char && !isWhitespace) { + return false; + } + } + return true; } function resolveEmbeddableImageUrl(url: string): string | null { @@ -145,12 +167,17 @@ function resolveEmbeddableImageUrl(url: string): string | null { if (trimmed.length > MAX_WEBCHAT_IMAGE_DATA_URL_CHARS) { return null; } - const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)$/i.exec(trimmed); - if (!match) { + const commaIndex = trimmed.indexOf(","); + if (commaIndex < 0) { + return null; + } + const metadata = trimmed.slice(0, commaIndex); + const match = /^data:(image\/[a-z0-9.+-]+);base64$/i.exec(metadata); + const base64Data = trimmed.slice(commaIndex + 1); + if (!match || !isBase64DataPayload(base64Data)) { return null; } const mediaType = normalizeLowercaseStringOrEmpty(match[1]); - const base64Data = match[2]; if (!ALLOWED_WEBCHAT_DATA_IMAGE_MEDIA_TYPES.has(mediaType)) { return null; }