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 <steipete@gmail.com>
(cherry picked from commit b85531c62f)
This commit is contained in:
thomas.szbay
2026-07-17 02:13:27 +08:00
committed by Dallin Romney
parent 028fc97809
commit 5400d5bedf
2 changed files with 44 additions and 1 deletions
+25
View File
@@ -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)",
},
]);
});
});
+19 -1
View File
@@ -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<ToolContentBlock, { type: "text" }>;
// 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<string, unknown> & { 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({