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

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