fix(security): harden session export image data-url handling

This commit is contained in:
Peter Steinberger
2026-02-24 02:52:33 +00:00
parent fefc414576
commit e578521ef4
8 changed files with 138 additions and 15 deletions

18
src/media/base64.test.ts Normal file
View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { canonicalizeBase64, estimateBase64DecodedBytes } from "./base64.js";
describe("base64 helpers", () => {
it("normalizes whitespace and keeps valid base64", () => {
const input = " SGV s bG8= \n";
expect(canonicalizeBase64(input)).toBe("SGVsbG8=");
});
it("rejects invalid base64 characters", () => {
const input = 'SGVsbG8=" onerror="alert(1)';
expect(canonicalizeBase64(input)).toBeUndefined();
});
it("estimates decoded bytes with whitespace", () => {
expect(estimateBase64DecodedBytes("SGV s bG8= \n")).toBe(5);
});
});

View File

@@ -35,3 +35,17 @@ export function estimateBase64DecodedBytes(base64: string): number {
const estimated = Math.floor((effectiveLen * 3) / 4) - padding;
return Math.max(0, estimated);
}
const BASE64_CHARS_RE = /^[A-Za-z0-9+/]+={0,2}$/;
/**
* Normalize and validate a base64 string.
* Returns canonical base64 (no whitespace) or undefined when invalid.
*/
export function canonicalizeBase64(base64: string): string | undefined {
const cleaned = base64.replace(/\s+/g, "");
if (!cleaned || cleaned.length % 4 !== 0 || !BASE64_CHARS_RE.test(cleaned)) {
return undefined;
}
return cleaned;
}

View File

@@ -113,3 +113,42 @@ describe("base64 size guards", () => {
fromSpy.mockRestore();
});
});
describe("input image base64 validation", () => {
it("rejects malformed base64 payloads", async () => {
await expect(
extractImageContentFromSource(
{
type: "base64",
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2N4j8AAAAASUVORK5CYII=" onerror="alert(1)',
mediaType: "image/png",
},
{
allowUrl: false,
allowedMimes: new Set(["image/png"]),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: 1,
},
),
).rejects.toThrow("invalid 'data' field");
});
it("normalizes whitespace in valid base64 payloads", async () => {
const image = await extractImageContentFromSource(
{
type: "base64",
data: " aGVs bG8= \n",
mediaType: "image/png",
},
{
allowUrl: false,
allowedMimes: new Set(["image/png"]),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: 1,
},
);
expect(image.data).toBe("aGVsbG8=");
});
});

View File

@@ -1,7 +1,7 @@
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { logWarn } from "../logger.js";
import { estimateBase64DecodedBytes } from "./base64.js";
import { canonicalizeBase64, estimateBase64DecodedBytes } from "./base64.js";
import { readResponseWithLimit } from "./read-response-with-limit.js";
type CanvasModule = typeof import("@napi-rs/canvas");
@@ -309,17 +309,21 @@ export async function extractImageContentFromSource(
throw new Error("input_image base64 source missing 'data' field");
}
rejectOversizedBase64Payload({ data: source.data, maxBytes: limits.maxBytes, label: "Image" });
const canonicalData = canonicalizeBase64(source.data);
if (!canonicalData) {
throw new Error("input_image base64 source has invalid 'data' field");
}
const mimeType = normalizeMimeType(source.mediaType) ?? "image/png";
if (!limits.allowedMimes.has(mimeType)) {
throw new Error(`Unsupported image MIME type: ${mimeType}`);
}
const buffer = Buffer.from(source.data, "base64");
const buffer = Buffer.from(canonicalData, "base64");
if (buffer.byteLength > limits.maxBytes) {
throw new Error(
`Image too large: ${buffer.byteLength} bytes (limit: ${limits.maxBytes} bytes)`,
);
}
return { type: "image", data: source.data, mimeType };
return { type: "image", data: canonicalData, mimeType };
}
if (source.type === "url" && source.url) {
@@ -362,10 +366,14 @@ export async function extractFileContentFromSource(params: {
throw new Error("input_file base64 source missing 'data' field");
}
rejectOversizedBase64Payload({ data: source.data, maxBytes: limits.maxBytes, label: "File" });
const canonicalData = canonicalizeBase64(source.data);
if (!canonicalData) {
throw new Error("input_file base64 source has invalid 'data' field");
}
const parsed = parseContentType(source.mediaType);
mimeType = parsed.mimeType;
charset = parsed.charset;
buffer = Buffer.from(source.data, "base64");
buffer = Buffer.from(canonicalData, "base64");
} else if (source.type === "url" && source.url) {
if (!limits.allowUrl) {
throw new Error("input_file URL sources are disabled by config");