fix #99163: harden large chat attachment parsing (#99213)

* fix(gateway): harden large chat attachment parsing

* test(ui): prove large chat image paste

* fix(media): preserve short MIME rejection

---------

Co-authored-by: xjch <267882353+jincheng-xydt@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
jincheng-xydt
2026-07-06 16:47:21 +08:00
committed by GitHub
parent 310c2f58b9
commit ba9700d59a
7 changed files with 377 additions and 17 deletions
+7
View File
@@ -7,6 +7,13 @@ describe("base64 helpers", () => {
expect(actual).toBe(expected);
}
it("canonicalizeBase64 validates large payloads without cons-string overflow", () => {
const encoded = Buffer.alloc(1_900_000).toString("base64");
expect(canonicalizeBase64(encoded)).toBe(encoded);
expect(canonicalizeBase64(encoded + "!")).toBeUndefined();
});
it.each([
{
name: "canonicalizeBase64 normalizes whitespace and keeps valid base64",
+24 -7
View File
@@ -37,6 +37,8 @@ export function estimateBase64DecodedBytes(base64: string): number {
return Math.max(0, estimated);
}
const CANONICALIZE_BASE64_CHUNK_SIZE = 8192;
function isBase64DataChar(code: number): boolean {
return (
(code >= 0x41 && code <= 0x5a) ||
@@ -52,9 +54,21 @@ function isBase64DataChar(code: number): boolean {
* base64 only when the input has valid alphabet, padding, and length.
*/
export function canonicalizeBase64(base64: string): string | undefined {
let cleaned = "";
const chunks: string[] = [];
let current = "";
let cleanedLength = 0;
let padding = 0;
let sawPadding = false;
const append = (char: string): void => {
current += char;
cleanedLength += 1;
if (current.length >= CANONICALIZE_BASE64_CHUNK_SIZE) {
chunks.push(current);
current = "";
}
};
for (let i = 0; i < base64.length; i += 1) {
const code = base64.charCodeAt(i);
if (code <= 0x20) {
@@ -66,23 +80,26 @@ export function canonicalizeBase64(base64: string): string | undefined {
return undefined;
}
sawPadding = true;
cleaned += "=";
append("=");
continue;
}
if (sawPadding || !isBase64DataChar(code)) {
return undefined;
}
cleaned += base64[i];
append(base64[i] ?? "");
}
if (!cleaned) {
if (cleanedLength === 0) {
return undefined;
}
const remainder = cleaned.length % 4;
const remainder = cleanedLength % 4;
if (remainder !== 0) {
if (sawPadding || remainder === 1) {
return undefined;
}
cleaned += "=".repeat(4 - remainder);
current += "=".repeat(4 - remainder);
}
return cleaned;
if (current) {
chunks.push(current);
}
return chunks.join("");
}
+25
View File
@@ -145,6 +145,31 @@ describe("parseMessageWithAttachments", () => {
expect(parsed.images[0]?.data).toBe(PNG_1x1);
});
it("parses large clipboard data URL images without full base64 decoding", async () => {
const png = Buffer.concat([Buffer.from(PNG_1x1, "base64"), Buffer.alloc(1_900_000)]);
const base64 = png.toString("base64");
const fromSpy = vi.spyOn(Buffer, "from");
try {
const parsed = await parseMessageWithAttachments(
"see screenshot",
[pngAttachment({ content: `data:image/png;base64,${base64}`, fileName: "screenshot.png" })],
{ log: { warn: () => {} } },
);
expectSingleInlinePng(parsed);
expect(parsed.images[0]?.data).toBe(base64);
expect(
fromSpy.mock.calls.some((call) => {
const [value, encoding] = call as unknown[];
return value === base64 && encoding === "base64";
}),
).toBe(false);
expect(saveMediaBufferMock).not.toHaveBeenCalled();
} finally {
fromSpy.mockRestore();
}
});
it("sniffs mime when missing", async () => {
const { parsed, logs } = await parseWithWarnings("see this", [
pngAttachment({ mimeType: undefined }),
+28 -1
View File
@@ -146,11 +146,38 @@ function resolveAttachmentMime(params: {
);
}
function isBase64DataCharCode(code: number): boolean {
return (
(code >= 0x41 && code <= 0x5a) ||
(code >= 0x61 && code <= 0x7a) ||
(code >= 0x30 && code <= 0x39) ||
code === 0x2b ||
code === 0x2f
);
}
function isValidBase64(value: string): boolean {
if (value.length === 0 || value.length % 4 !== 0) {
return false;
}
return /^[A-Za-z0-9+/]+={0,2}$/.test(value);
let padding = 0;
let sawPadding = false;
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code === 0x3d) {
padding += 1;
if (padding > 2) {
return false;
}
sawPadding = true;
continue;
}
if (sawPadding || !isBase64DataCharCode(code)) {
return false;
}
}
return true;
}
function verifyDecodedSize(buffer: Buffer, estimatedBytes: number, label: string): void {
+24
View File
@@ -13,4 +13,28 @@ describe("sniffMimeFromBase64", () => {
await expect(sniffMimeFromBase64(onePixelPng)).resolves.toBe("image/png");
});
it("rejects MIME signatures shorter than two base64 quads", async () => {
await expect(
sniffMimeFromBase64(Buffer.from("BM").toString("base64")),
).resolves.toBeUndefined();
await expect(
sniffMimeFromBase64(Buffer.from([0xff, 0xd8, 0xff]).toString("base64")),
).resolves.toBeUndefined();
});
it("sniffs large base64 payloads from their prefix", async () => {
const onePixelPng =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
const png = Buffer.concat([Buffer.from(onePixelPng, "base64"), Buffer.alloc(1_900_000)]);
await expect(sniffMimeFromBase64(png.toString("base64"))).resolves.toBe("image/png");
});
it("rejects malformed data after a valid MIME prefix", async () => {
const onePixelPng =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
await expect(sniffMimeFromBase64(onePixelPng + "!")).resolves.toBeUndefined();
});
});
+11 -9
View File
@@ -2,23 +2,25 @@
import { canonicalizeBase64 } from "@openclaw/media-core/base64";
import { detectMime } from "@openclaw/media-core/mime";
/** Sniffs a MIME type from canonical base64 without decoding the full payload. */
const BASE64_SNIFF_PREFIX_CHARS = 256;
/** Sniffs a MIME type from a small base64 prefix after validating the full payload. */
export async function sniffMimeFromBase64(base64: string): Promise<string | undefined> {
const trimmed = base64.trim();
const canonicalBase64 = trimmed ? canonicalizeBase64(trimmed) : undefined;
if (!canonicalBase64) {
const canonical = canonicalizeBase64(base64);
if (!canonical) {
return undefined;
}
const take = Math.min(256, canonicalBase64.length);
const sliceLen = take - (take % 4);
// Need at least two base64 quads so magic-byte sniffers see more than a trivial prefix.
if (sliceLen < 8) {
const take = Math.min(BASE64_SNIFF_PREFIX_CHARS, canonical.length);
const sliceLength = take - (take % 4);
// Keep the existing minimum so short magic-byte prefixes are not treated as complete media.
if (sliceLength < 8) {
return undefined;
}
try {
const head = Buffer.from(canonicalBase64.slice(0, sliceLen), "base64");
const canonicalPrefix = canonical.slice(0, sliceLength);
const head = Buffer.from(canonicalPrefix, "base64");
return await detectMime({ buffer: head });
} catch {
return undefined;
@@ -0,0 +1,258 @@
// Control UI regression proof for #99213: paste a large screenshot-like PNG through the
// real chat composer and verify chat.send receives it without overflowing base64 handling.
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat-large-paste-99213");
const viewport = { height: 900, width: 1280 };
let server: ControlUiE2eServer;
type RecordedPage = {
browser: Browser;
context: BrowserContext;
page: Page;
rawVideoDir: string;
};
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label} to be an object`);
}
return value as Record<string, unknown>;
}
function requireString(value: unknown, label: string): string {
if (typeof value !== "string" || !value) {
throw new Error(`Expected non-empty ${label}`);
}
return value;
}
function requireArray(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Expected ${label} to be an array`);
}
return value;
}
function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function uint32(value: number): Uint8Array {
return Uint8Array.of(
(value >>> 24) & 0xff,
(value >>> 16) & 0xff,
(value >>> 8) & 0xff,
value & 0xff,
);
}
function ascii(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
function chunk(type: string, payload: Uint8Array): Uint8Array {
const typeBytes = ascii(type);
const crcInput = new Uint8Array(typeBytes.length + payload.length);
crcInput.set(typeBytes, 0);
crcInput.set(payload, typeBytes.length);
const result = new Uint8Array(12 + payload.length);
result.set(uint32(payload.length), 0);
result.set(typeBytes, 4);
result.set(payload, 8);
result.set(uint32(crc32(crcInput)), 8 + payload.length);
return result;
}
function concatBytes(parts: Uint8Array[]): Uint8Array {
const result = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let offset = 0;
for (const part of parts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
function createLargePngBytes(targetBytes: number): Uint8Array {
const signature = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a);
const ihdr = chunk("IHDR", Uint8Array.of(0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0));
const idat = chunk(
"IDAT",
Uint8Array.of(0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff),
);
const iend = chunk("IEND", new Uint8Array());
const fixedBytes = signature.length + ihdr.length + idat.length + iend.length + 12;
const payload = new Uint8Array(Math.max(0, targetBytes - fixedBytes));
for (let i = 0; i < payload.length; i += 1) {
payload[i] = 0x41 + (i % 26);
}
return concatBytes([signature, ihdr, chunk("tEXt", payload), idat, iend]);
}
function toBase64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString("base64");
}
async function newRecordedPage(label: string): Promise<RecordedPage> {
await mkdir(artifactDir, { recursive: true });
const rawVideoDir = path.join(artifactDir, `${label}-raw`);
await rm(rawVideoDir, { force: true, recursive: true });
await mkdir(rawVideoDir, { recursive: true });
const browser = await chromium.launch({ executablePath: chromiumExecutablePath });
let context: BrowserContext | undefined;
let page: Page | undefined;
try {
context = await browser.newContext({
locale: "en-US",
permissions: ["clipboard-read", "clipboard-write"],
recordVideo: {
dir: rawVideoDir,
size: viewport,
},
serviceWorkers: "block",
viewport,
});
page = await context.newPage();
page.setDefaultTimeout(10_000);
return { browser, context, page, rawVideoDir };
} catch (error) {
await page?.close().catch(() => {});
await context?.close().catch(() => {});
await browser.close().catch(() => {});
await rm(rawVideoDir, { force: true, recursive: true });
throw error;
}
}
async function closeRecordedPage(recorded: RecordedPage, label: string): Promise<string[]> {
const video = recorded.page.video();
const videos: string[] = [];
try {
await recorded.context.close();
if (video) {
const rawVideoPath = await video.path();
const videoPath = path.join(artifactDir, `${label}.webm`);
await copyFile(rawVideoPath, videoPath);
videos.push(videoPath);
}
} finally {
await recorded.browser.close().catch(() => {});
await rm(recorded.rawVideoDir, { force: true, recursive: true });
}
return videos;
}
describeControlUiE2e("Control UI #99213 large screenshot paste proof", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(
`Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a compatible browser, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
);
}
server = await startControlUiE2eServer();
});
afterAll(async () => {
await server?.close();
});
it("pastes and sends a roughly 2 MB PNG through the chat composer", async () => {
await rm(artifactDir, { force: true, recursive: true });
await mkdir(artifactDir, { recursive: true });
const pngBytes = createLargePngBytes(1_901_669);
const imageBase64 = toBase64(pngBytes);
const dataUrl = `data:image/png;base64,${imageBase64}`;
const prompt = "proof: large Control UI clipboard image";
const recorded = await newRecordedPage("large-paste");
const screenshots: string[] = [];
let videos: string[];
try {
const gateway = await installMockGateway(recorded.page, {
historyMessages: [
{
content: [{ text: "Ready for #99213 large screenshot paste proof.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
],
});
await recorded.page.goto(`${server.baseUrl}chat`);
await recorded.page
.getByText("Ready for #99213 large screenshot paste proof.")
.waitFor({ timeout: 10_000 });
const composer = recorded.page.locator(".agent-chat__composer-combobox textarea");
await composer.focus();
await recorded.page.evaluate(async (text) => {
await navigator.clipboard.writeText(text);
}, dataUrl);
await composer.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
await recorded.page.locator(".chat-attachment-thumb").waitFor({ state: "visible" });
await composer.fill(prompt);
const pasteScreenshot = path.join(artifactDir, "01-pasted-large-image.png");
await recorded.page.screenshot({ fullPage: true, path: pasteScreenshot });
screenshots.push(pasteScreenshot);
await recorded.page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(sendRequest.params, "chat.send params");
const attachments = requireArray(params.attachments, "chat.send attachments");
expect(params.message).toBe(prompt);
expect(attachments).toHaveLength(1);
const attachment = requireRecord(attachments[0], "chat.send attachment");
expect(attachment.type).toBe("image");
expect(attachment.mimeType).toBe("image/png");
expect(attachment.fileName).toBe("pasted-image.png");
expect(requireString(attachment.content, "attachment content")).toBe(imageBase64);
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
await gateway.emitChatFinal({ runId, text: "Large screenshot paste proof received." });
await recorded.page
.getByText("Large screenshot paste proof received.")
.waitFor({ timeout: 10_000 });
const sentScreenshot = path.join(artifactDir, "02-sent-large-image.png");
await recorded.page.screenshot({ fullPage: true, path: sentScreenshot });
screenshots.push(sentScreenshot);
} finally {
videos = await closeRecordedPage(recorded, "large-paste");
}
const summary = {
base64Chars: imageBase64.length,
dataUrlChars: dataUrl.length,
pngBytes: pngBytes.length,
screenshots,
videos,
};
await writeFile(
path.join(artifactDir, "summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
);
});
});