mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:38:38 +00:00
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.
|
- 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/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/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
|
## 2026.2.14
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildMessageWithAttachments,
|
buildMessageWithAttachments,
|
||||||
type ChatAttachment,
|
type ChatAttachment,
|
||||||
@@ -44,16 +44,20 @@ describe("buildMessageWithAttachments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects images over limit", () => {
|
it("rejects images over limit", () => {
|
||||||
const big = Buffer.alloc(6_000_000, 0).toString("base64");
|
const big = "A".repeat(10_000);
|
||||||
const att: ChatAttachment = {
|
const att: ChatAttachment = {
|
||||||
type: "image",
|
type: "image",
|
||||||
mimeType: "image/png",
|
mimeType: "image/png",
|
||||||
fileName: "big.png",
|
fileName: "big.png",
|
||||||
content: big,
|
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,
|
/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 () => {
|
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(
|
await expect(
|
||||||
parseMessageWithAttachments(
|
parseMessageWithAttachments(
|
||||||
"x",
|
"x",
|
||||||
@@ -106,9 +111,12 @@ describe("parseMessageWithAttachments", () => {
|
|||||||
content: big,
|
content: big,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ maxBytes: 5_000_000, log: { warn: () => {} } },
|
{ maxBytes: 16, log: { warn: () => {} } },
|
||||||
),
|
),
|
||||||
).rejects.toThrow(/exceeds size limit/i);
|
).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 () => {
|
it("sniffs mime when missing", async () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { estimateBase64DecodedBytes } from "../media/base64.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
|
|
||||||
export type ChatAttachment = {
|
export type ChatAttachment = {
|
||||||
@@ -54,6 +55,11 @@ function isImageMime(mime?: string): boolean {
|
|||||||
return typeof mime === "string" && mime.startsWith("image/");
|
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.
|
* Parse attachments and extract images as structured content blocks.
|
||||||
* Returns the message text and an array of image content blocks
|
* Returns the message text and an array of image content blocks
|
||||||
@@ -91,15 +97,10 @@ export async function parseMessageWithAttachments(
|
|||||||
if (dataUrlMatch) {
|
if (dataUrlMatch) {
|
||||||
b64 = dataUrlMatch[1];
|
b64 = dataUrlMatch[1];
|
||||||
}
|
}
|
||||||
// Basic base64 sanity: length multiple of 4 and charset check.
|
if (!isValidBase64(b64)) {
|
||||||
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 {
|
|
||||||
throw new Error(`attachment ${label}: invalid base64 content`);
|
throw new Error(`attachment ${label}: invalid base64 content`);
|
||||||
}
|
}
|
||||||
|
sizeBytes = estimateBase64DecodedBytes(b64);
|
||||||
if (sizeBytes <= 0 || sizeBytes > maxBytes) {
|
if (sizeBytes <= 0 || sizeBytes > maxBytes) {
|
||||||
throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`);
|
throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`);
|
||||||
}
|
}
|
||||||
@@ -163,15 +164,10 @@ export function buildMessageWithAttachments(
|
|||||||
|
|
||||||
let sizeBytes = 0;
|
let sizeBytes = 0;
|
||||||
const b64 = content.trim();
|
const b64 = content.trim();
|
||||||
// Basic base64 sanity: length multiple of 4 and charset check.
|
if (!isValidBase64(b64)) {
|
||||||
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 {
|
|
||||||
throw new Error(`attachment ${label}: invalid base64 content`);
|
throw new Error(`attachment ${label}: invalid base64 content`);
|
||||||
}
|
}
|
||||||
|
sizeBytes = estimateBase64DecodedBytes(b64);
|
||||||
if (sizeBytes <= 0 || sizeBytes > maxBytes) {
|
if (sizeBytes <= 0 || sizeBytes > maxBytes) {
|
||||||
throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`);
|
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 () => {
|
it("rejects oversized base64 images before decoding", async () => {
|
||||||
const data = Buffer.alloc(7).toString("base64");
|
const data = Buffer.alloc(7).toString("base64");
|
||||||
const { extractImageContentFromSource } = await import("./input-files.js");
|
const { extractImageContentFromSource } = await import("./input-files.js");
|
||||||
|
const fromSpy = vi.spyOn(Buffer, "from");
|
||||||
await expect(
|
await expect(
|
||||||
extractImageContentFromSource(
|
extractImageContentFromSource(
|
||||||
{ type: "base64", data, mediaType: "image/png" },
|
{ type: "base64", data, mediaType: "image/png" },
|
||||||
@@ -70,11 +71,17 @@ describe("base64 size guards", () => {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
).rejects.toThrow("Image too large");
|
).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 () => {
|
it("rejects oversized base64 files before decoding", async () => {
|
||||||
const data = Buffer.alloc(7).toString("base64");
|
const data = Buffer.alloc(7).toString("base64");
|
||||||
const { extractFileContentFromSource } = await import("./input-files.js");
|
const { extractFileContentFromSource } = await import("./input-files.js");
|
||||||
|
const fromSpy = vi.spyOn(Buffer, "from");
|
||||||
await expect(
|
await expect(
|
||||||
extractFileContentFromSource({
|
extractFileContentFromSource({
|
||||||
source: { type: "base64", data, mediaType: "text/plain", filename: "x.txt" },
|
source: { type: "base64", data, mediaType: "text/plain", filename: "x.txt" },
|
||||||
@@ -89,5 +96,9 @@ describe("base64 size guards", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("File too large");
|
).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 type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||||
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
|
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
|
||||||
import { logWarn } from "../logger.js";
|
import { logWarn } from "../logger.js";
|
||||||
|
import { estimateBase64DecodedBytes } from "./base64.js";
|
||||||
|
|
||||||
type CanvasModule = typeof import("@napi-rs/canvas");
|
type CanvasModule = typeof import("@napi-rs/canvas");
|
||||||
type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs");
|
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_MAX_PIXELS = 4_000_000;
|
||||||
export const DEFAULT_INPUT_PDF_MIN_TEXT_CHARS = 200;
|
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: {
|
function rejectOversizedBase64Payload(params: {
|
||||||
data: string;
|
data: string;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user