fix(gateway): bound webchat image data scans

This commit is contained in:
Vincent Koc
2026-05-27 20:40:43 +02:00
parent 2f710f5604
commit bde1bad2c1
2 changed files with 57 additions and 7 deletions

View File

@@ -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([
{

View File

@@ -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;
}