From 5400d5bedf774ea8da34072497c6941d83db6594 Mon Sep 17 00:00:00 2001 From: "thomas.szbay" Date: Fri, 17 Jul 2026 02:13:27 +0800 Subject: [PATCH] fix(agents): bound base64 image input before decode in tool-image sanitizer (#105086) * fix(agents): bound base64 image input before decode in tool-image sanitizer * fix(agents): lower input-size cap to 10MB for OOM headroom * fix(agents): align tool-image input-cap comment with 10 MiB ceiling * fix(agents): typecheck-safe access in tool-image input-cap test * test(agents): exercise real tool image input cap --------- Co-authored-by: Peter Steinberger (cherry picked from commit b85531c62f7565856219f652bf9fe6cfa1940b1f) --- src/agents/tool-images.input-cap.test.ts | 25 ++++++++++++++++++++++++ src/agents/tool-images.ts | 20 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/agents/tool-images.input-cap.test.ts diff --git a/src/agents/tool-images.input-cap.test.ts b/src/agents/tool-images.input-cap.test.ts new file mode 100644 index 000000000000..6a526933af47 --- /dev/null +++ b/src/agents/tool-images.input-cap.test.ts @@ -0,0 +1,25 @@ +// Tool image input-cap tests verify pathological oversized base64 input is +// rejected before Buffer.from allocates a transient multi-MB buffer. +import { describe, expect, it } from "vitest"; +import { sanitizeContentBlocksImages } from "./tool-images.js"; + +const MAX_IMAGE_INPUT_BYTES = 10 * 1024 * 1024; + +describe("tool image sanitizer oversized input cap", () => { + it("rejects oversized estimated input before decode allocation", async () => { + const encodedLength = Math.ceil(((MAX_IMAGE_INPUT_BYTES + 1) * 4) / 3 / 4) * 4; + const oversizedBase64 = "A".repeat(encodedLength); + + const out = await sanitizeContentBlocksImages( + [{ type: "image" as const, data: oversizedBase64, mimeType: "image/png" }], + "test", + ); + + expect(out).toStrictEqual([ + { + type: "text", + text: "[test] omitted image payload: image exceeds input size limit (10.00MB)", + }, + ]); + }); +}); diff --git a/src/agents/tool-images.ts b/src/agents/tool-images.ts index 0550c3a55348..7fc219a495d1 100644 --- a/src/agents/tool-images.ts +++ b/src/agents/tool-images.ts @@ -3,7 +3,7 @@ * * Downscales and recompresses oversized base64 image blocks before provider replay. */ -import { canonicalizeBase64 } from "@openclaw/media-core/base64"; +import { canonicalizeBase64, estimateBase64DecodedBytes } from "@openclaw/media-core/base64"; import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; import { toErrorObject } from "../infra/errors.js"; import type { ImageContent } from "../llm/types.js"; @@ -33,6 +33,11 @@ type TextContentBlock = Extract; // tool outputs do not break later turns or silent channel replies. const MAX_IMAGE_DIMENSION_PX = DEFAULT_IMAGE_MAX_DIMENSION_PX; const MAX_IMAGE_BYTES = DEFAULT_IMAGE_MAX_BYTES; +// Hard cap on decoded input bytes before Buffer.from/resizer allocation. A +// conservative limit well below demonstrated OOM thresholds, leaving headroom +// for canonicalization, decode, and image-processing allocations while still +// permitting legitimate tool-output images. +const MAX_IMAGE_INPUT_BYTES = 10 * 1024 * 1024; const log = createSubsystemLogger("agents/tool-images"); function isImageTypeBlock(block: unknown): block is Record & { type: "image" } { @@ -327,6 +332,19 @@ export async function sanitizeContentBlocksImages( continue; } + // Estimate decoded bytes on the raw payload before trim/canonicalize/decode + // so pathological multi-GB base64 cannot force a transient large allocation. + // maxBytes is the post-decode resize target; MAX_IMAGE_INPUT_BYTES is a + // conservative pre-decode ceiling (10 MiB) far below the 25MP/100MB + // processing headroom, so legitimate tool images still decode and resize. + if (estimateBase64DecodedBytes(block.data) > MAX_IMAGE_INPUT_BYTES) { + out.push({ + type: "text", + text: `[${label}] omitted image payload: image exceeds input size limit (${formatBytesShort(MAX_IMAGE_INPUT_BYTES)})`, + } satisfies TextContentBlock); + continue; + } + const data = block.data.trim(); if (!data) { out.push({