mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 05:07:27 +00:00
fix(security): harden session export image data-url handling
This commit is contained in:
18
src/media/base64.test.ts
Normal file
18
src/media/base64.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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=");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user