fix(security): reject oversized base64 before decode

This commit is contained in:
Peter Steinberger
2026-02-14 15:45:04 +01:00
parent 4f043991e0
commit 31791233d6
6 changed files with 74 additions and 29 deletions

View File

@@ -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/<email>` 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

View File

@@ -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 () => {

View File

@@ -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)`);
}

37
src/media/base64.ts Normal file
View File

@@ -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);
}

View File

@@ -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();
});
});

View File

@@ -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;