mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
* 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:
@@ -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",
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user