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

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings.
- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads.
## 2026.2.23 (Unreleased)

View File

@@ -107,4 +107,22 @@ describe("tool image sanitizing", () => {
const image = getImageBlock(out);
expect(image.mimeType).toBe("image/jpeg");
});
it("drops malformed image base64 payloads", async () => {
const blocks = [
{
type: "image" as const,
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2N4j8AAAAASUVORK5CYII=" onerror="alert(1)',
mimeType: "image/png",
},
];
const out = await sanitizeContentBlocksImages(blocks, "test");
expect(out).toEqual([
{
type: "text",
text: "[test] omitted image payload: invalid base64",
},
]);
});
});

View File

@@ -1,6 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { canonicalizeBase64 } from "../media/base64.js";
import {
buildImageResizeSideGrid,
getImageMetadata,
@@ -296,13 +297,21 @@ export async function sanitizeContentBlocksImages(
} satisfies TextContentBlock);
continue;
}
const canonicalData = canonicalizeBase64(data);
if (!canonicalData) {
out.push({
type: "text",
text: `[${label}] omitted image payload: invalid base64`,
} satisfies TextContentBlock);
continue;
}
try {
const inferredMimeType = inferMimeTypeFromBase64(data);
const inferredMimeType = inferMimeTypeFromBase64(canonicalData);
const mimeType = inferredMimeType ?? block.mimeType;
const fileName = inferImageFileName({ block, label, mediaPathHint });
const resized = await resizeImageBase64IfNeeded({
base64: data,
base64: canonicalData,
mimeType,
maxDimensionPx,
maxBytes,

View File

@@ -665,15 +665,36 @@
return div.innerHTML;
}
// Validate image MIME type to prevent attribute injection in data-URL src.
// Validate image fields before interpolating data URLs.
const SAFE_IMAGE_MIME_RE = /^image\/(png|jpeg|gif|webp|svg\+xml|bmp|tiff|avif)$/i;
const SAFE_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
function sanitizeImageMimeType(mimeType) {
if (typeof mimeType === "string" && SAFE_IMAGE_MIME_RE.test(mimeType)) {
return mimeType;
return mimeType.toLowerCase();
}
return "application/octet-stream";
}
function sanitizeImageBase64(data) {
if (typeof data !== "string") {
return "";
}
const cleaned = data.replace(/\s+/g, "");
if (!cleaned || cleaned.length % 4 !== 0 || !SAFE_BASE64_RE.test(cleaned)) {
return "";
}
return cleaned;
}
function renderDataUrlImage(img, className) {
const mimeType = sanitizeImageMimeType(img?.mimeType);
const base64 = sanitizeImageBase64(img?.data);
if (!base64) {
return "";
}
return `<img src="data:${mimeType};base64,${base64}" class="${className}" />`;
}
/**
* Truncate string to maxLen chars, append "..." if truncated.
*/
@@ -1037,12 +1058,7 @@
}
return (
'<div class="tool-images">' +
images
.map(
(img) =>
`<img src="data:${sanitizeImageMimeType(img.mimeType)};base64,${img.data}" class="tool-image" />`,
)
.join("") +
images.map((img) => renderDataUrlImage(img, "tool-image")).join("") +
"</div>"
);
};
@@ -1315,7 +1331,7 @@
if (images.length > 0) {
html += '<div class="message-images">';
for (const img of images) {
html += `<img src="data:${sanitizeImageMimeType(img.mimeType)};base64,${img.data}" class="message-image" />`;
html += renderDataUrlImage(img, "message-image");
}
html += "</div>";
}

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");