diff --git a/CHANGELOG.md b/CHANGELOG.md index 589cd0ba1..6c4a033ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Docs: https://docs.openclaw.ai - Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. - Security/Google Chat: deprecate `users/` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc. - Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc. +- Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc. +- Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc. ## 2026.2.14 diff --git a/src/gateway/chat-attachments.test.ts b/src/gateway/chat-attachments.test.ts index 435560472..e13d0ee19 100644 --- a/src/gateway/chat-attachments.test.ts +++ b/src/gateway/chat-attachments.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildMessageWithAttachments, type ChatAttachment, @@ -44,16 +44,20 @@ describe("buildMessageWithAttachments", () => { }); it("rejects images over limit", () => { - const big = Buffer.alloc(6_000_000, 0).toString("base64"); + const big = "A".repeat(10_000); const att: ChatAttachment = { type: "image", mimeType: "image/png", fileName: "big.png", content: big, }; - expect(() => buildMessageWithAttachments("x", [att], { maxBytes: 5_000_000 })).toThrow( + const fromSpy = vi.spyOn(Buffer, "from"); + expect(() => buildMessageWithAttachments("x", [att], { maxBytes: 16 })).toThrow( /exceeds size limit/i, ); + const base64Calls = fromSpy.mock.calls.filter((args) => args[1] === "base64"); + expect(base64Calls).toHaveLength(0); + fromSpy.mockRestore(); }); }); @@ -94,7 +98,8 @@ describe("parseMessageWithAttachments", () => { }); it("rejects images over limit", async () => { - const big = Buffer.alloc(6_000_000, 0).toString("base64"); + const big = "A".repeat(10_000); + const fromSpy = vi.spyOn(Buffer, "from"); await expect( parseMessageWithAttachments( "x", @@ -106,9 +111,12 @@ describe("parseMessageWithAttachments", () => { content: big, }, ], - { maxBytes: 5_000_000, log: { warn: () => {} } }, + { maxBytes: 16, log: { warn: () => {} } }, ), ).rejects.toThrow(/exceeds size limit/i); + const base64Calls = fromSpy.mock.calls.filter((args) => args[1] === "base64"); + expect(base64Calls).toHaveLength(0); + fromSpy.mockRestore(); }); it("sniffs mime when missing", async () => { diff --git a/src/gateway/chat-attachments.ts b/src/gateway/chat-attachments.ts index 4a4308b57..b9e781840 100644 --- a/src/gateway/chat-attachments.ts +++ b/src/gateway/chat-attachments.ts @@ -1,3 +1,4 @@ +import { estimateBase64DecodedBytes } from "../media/base64.js"; import { detectMime } from "../media/mime.js"; export type ChatAttachment = { @@ -54,6 +55,11 @@ function isImageMime(mime?: string): boolean { return typeof mime === "string" && mime.startsWith("image/"); } +function isValidBase64(value: string): boolean { + // Minimal validation; avoid full decode allocations for large payloads. + return value.length > 0 && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value); +} + /** * Parse attachments and extract images as structured content blocks. * Returns the message text and an array of image content blocks @@ -91,15 +97,10 @@ export async function parseMessageWithAttachments( if (dataUrlMatch) { b64 = dataUrlMatch[1]; } - // Basic base64 sanity: length multiple of 4 and charset check. - if (b64.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(b64)) { - throw new Error(`attachment ${label}: invalid base64 content`); - } - try { - sizeBytes = Buffer.from(b64, "base64").byteLength; - } catch { + if (!isValidBase64(b64)) { throw new Error(`attachment ${label}: invalid base64 content`); } + sizeBytes = estimateBase64DecodedBytes(b64); if (sizeBytes <= 0 || sizeBytes > maxBytes) { throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`); } @@ -163,15 +164,10 @@ export function buildMessageWithAttachments( let sizeBytes = 0; const b64 = content.trim(); - // Basic base64 sanity: length multiple of 4 and charset check. - if (b64.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(b64)) { - throw new Error(`attachment ${label}: invalid base64 content`); - } - try { - sizeBytes = Buffer.from(b64, "base64").byteLength; - } catch { + if (!isValidBase64(b64)) { throw new Error(`attachment ${label}: invalid base64 content`); } + sizeBytes = estimateBase64DecodedBytes(b64); if (sizeBytes <= 0 || sizeBytes > maxBytes) { throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`); } diff --git a/src/media/base64.ts b/src/media/base64.ts new file mode 100644 index 000000000..56a8626c3 --- /dev/null +++ b/src/media/base64.ts @@ -0,0 +1,37 @@ +export function estimateBase64DecodedBytes(base64: string): number { + // Avoid `trim()`/`replace()` here: they allocate a second (potentially huge) string. + // We only need a conservative decoded-size estimate to enforce budgets before Buffer.from(..., "base64"). + let effectiveLen = 0; + for (let i = 0; i < base64.length; i += 1) { + const code = base64.charCodeAt(i); + // Treat ASCII control + space as whitespace; base64 decoders commonly ignore these. + if (code <= 0x20) { + continue; + } + effectiveLen += 1; + } + + if (effectiveLen === 0) { + return 0; + } + + let padding = 0; + // Find last non-whitespace char(s) to detect '=' padding without allocating/copying. + let end = base64.length - 1; + while (end >= 0 && base64.charCodeAt(end) <= 0x20) { + end -= 1; + } + if (end >= 0 && base64[end] === "=") { + padding = 1; + end -= 1; + while (end >= 0 && base64.charCodeAt(end) <= 0x20) { + end -= 1; + } + if (end >= 0 && base64[end] === "=") { + padding = 2; + } + } + + const estimated = Math.floor((effectiveLen * 3) / 4) - padding; + return Math.max(0, estimated); +} diff --git a/src/media/input-files.fetch-guard.test.ts b/src/media/input-files.fetch-guard.test.ts index e4ab10b7a..d7a4fc829 100644 --- a/src/media/input-files.fetch-guard.test.ts +++ b/src/media/input-files.fetch-guard.test.ts @@ -58,6 +58,7 @@ describe("base64 size guards", () => { it("rejects oversized base64 images before decoding", async () => { const data = Buffer.alloc(7).toString("base64"); const { extractImageContentFromSource } = await import("./input-files.js"); + const fromSpy = vi.spyOn(Buffer, "from"); await expect( extractImageContentFromSource( { type: "base64", data, mediaType: "image/png" }, @@ -70,11 +71,17 @@ describe("base64 size guards", () => { }, ), ).rejects.toThrow("Image too large"); + + // Regression check: the oversize reject must happen before Buffer.from(..., "base64") allocates. + const base64Calls = fromSpy.mock.calls.filter((args) => args[1] === "base64"); + expect(base64Calls).toHaveLength(0); + fromSpy.mockRestore(); }); it("rejects oversized base64 files before decoding", async () => { const data = Buffer.alloc(7).toString("base64"); const { extractFileContentFromSource } = await import("./input-files.js"); + const fromSpy = vi.spyOn(Buffer, "from"); await expect( extractFileContentFromSource({ source: { type: "base64", data, mediaType: "text/plain", filename: "x.txt" }, @@ -89,5 +96,9 @@ describe("base64 size guards", () => { }, }), ).rejects.toThrow("File too large"); + + const base64Calls = fromSpy.mock.calls.filter((args) => args[1] === "base64"); + expect(base64Calls).toHaveLength(0); + fromSpy.mockRestore(); }); }); diff --git a/src/media/input-files.ts b/src/media/input-files.ts index 30784a990..e869ca3fb 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -1,6 +1,7 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { logWarn } from "../logger.js"; +import { estimateBase64DecodedBytes } from "./base64.js"; type CanvasModule = typeof import("@napi-rs/canvas"); type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs"); @@ -110,16 +111,6 @@ export const DEFAULT_INPUT_PDF_MAX_PAGES = 4; export const DEFAULT_INPUT_PDF_MAX_PIXELS = 4_000_000; export const DEFAULT_INPUT_PDF_MIN_TEXT_CHARS = 200; -function estimateBase64DecodedBytes(base64: string): number { - const cleaned = base64.trim().replace(/\s+/g, ""); - if (!cleaned) { - return 0; - } - const padding = cleaned.endsWith("==") ? 2 : cleaned.endsWith("=") ? 1 : 0; - const estimated = Math.floor((cleaned.length * 3) / 4) - padding; - return Math.max(0, estimated); -} - function rejectOversizedBase64Payload(params: { data: string; maxBytes: number;