fix(security): reject oversized base64 before decode
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
37
src/media/base64.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user