Skip to content

Commit e578521

Browse files
committed
fix(security): harden session export image data-url handling
1 parent fefc414 commit e578521

8 files changed

Lines changed: 138 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- 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.
2222
- 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.
2323
- 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.
24+
- 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.
2425

2526
## 2026.2.23 (Unreleased)
2627

src/agents/tool-images.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,22 @@ describe("tool image sanitizing", () => {
107107
const image = getImageBlock(out);
108108
expect(image.mimeType).toBe("image/jpeg");
109109
});
110+
111+
it("drops malformed image base64 payloads", async () => {
112+
const blocks = [
113+
{
114+
type: "image" as const,
115+
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2N4j8AAAAASUVORK5CYII=" onerror="alert(1)',
116+
mimeType: "image/png",
117+
},
118+
];
119+
120+
const out = await sanitizeContentBlocksImages(blocks, "test");
121+
expect(out).toEqual([
122+
{
123+
type: "text",
124+
text: "[test] omitted image payload: invalid base64",
125+
},
126+
]);
127+
});
110128
});

src/agents/tool-images.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
22
import type { ImageContent } from "@mariozechner/pi-ai";
33
import { createSubsystemLogger } from "../logging/subsystem.js";
4+
import { canonicalizeBase64 } from "../media/base64.js";
45
import {
56
buildImageResizeSideGrid,
67
getImageMetadata,
@@ -296,13 +297,21 @@ export async function sanitizeContentBlocksImages(
296297
} satisfies TextContentBlock);
297298
continue;
298299
}
300+
const canonicalData = canonicalizeBase64(data);
301+
if (!canonicalData) {
302+
out.push({
303+
type: "text",
304+
text: `[${label}] omitted image payload: invalid base64`,
305+
} satisfies TextContentBlock);
306+
continue;
307+
}
299308

300309
try {
301-
const inferredMimeType = inferMimeTypeFromBase64(data);
310+
const inferredMimeType = inferMimeTypeFromBase64(canonicalData);
302311
const mimeType = inferredMimeType ?? block.mimeType;
303312
const fileName = inferImageFileName({ block, label, mediaPathHint });
304313
const resized = await resizeImageBase64IfNeeded({
305-
base64: data,
314+
base64: canonicalData,
306315
mimeType,
307316
maxDimensionPx,
308317
maxBytes,

src/auto-reply/reply/export-html/template.js

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -665,15 +665,36 @@
665665
return div.innerHTML;
666666
}
667667

668-
// Validate image MIME type to prevent attribute injection in data-URL src.
668+
// Validate image fields before interpolating data URLs.
669669
const SAFE_IMAGE_MIME_RE = /^image\/(png|jpeg|gif|webp|svg\+xml|bmp|tiff|avif)$/i;
670+
const SAFE_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
671+
670672
function sanitizeImageMimeType(mimeType) {
671673
if (typeof mimeType === "string" && SAFE_IMAGE_MIME_RE.test(mimeType)) {
672-
return mimeType;
674+
return mimeType.toLowerCase();
673675
}
674676
return "application/octet-stream";
675677
}
676678

679+
function sanitizeImageBase64(data) {
680+
if (typeof data !== "string") {
681+
return "";
682+
}
683+
const cleaned = data.replace(/\s+/g, "");
684+
if (!cleaned || cleaned.length % 4 !== 0 || !SAFE_BASE64_RE.test(cleaned)) {
685+
return "";
686+
}
687+
return cleaned;
688+
}
689+
690+
function renderDataUrlImage(img, className) {
691+
const mimeType = sanitizeImageMimeType(img?.mimeType);
692+
const base64 = sanitizeImageBase64(img?.data);
693+
if (!base64) {
694+
return "";
695+
}
696+
return `<img src="data:${mimeType};base64,${base64}" class="${className}" />`;
697+
}
677698
/**
678699
* Truncate string to maxLen chars, append "..." if truncated.
679700
*/
@@ -1037,12 +1058,7 @@
10371058
}
10381059
return (
10391060
'<div class="tool-images">' +
1040-
images
1041-
.map(
1042-
(img) =>
1043-
`<img src="data:${sanitizeImageMimeType(img.mimeType)};base64,${img.data}" class="tool-image" />`,
1044-
)
1045-
.join("") +
1061+
images.map((img) => renderDataUrlImage(img, "tool-image")).join("") +
10461062
"</div>"
10471063
);
10481064
};
@@ -1315,7 +1331,7 @@
13151331
if (images.length > 0) {
13161332
html += '<div class="message-images">';
13171333
for (const img of images) {
1318-
html += `<img src="data:${sanitizeImageMimeType(img.mimeType)};base64,${img.data}" class="message-image" />`;
1334+
html += renderDataUrlImage(img, "message-image");
13191335
}
13201336
html += "</div>";
13211337
}

src/media/base64.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, it } from "vitest";
2+
import { canonicalizeBase64, estimateBase64DecodedBytes } from "./base64.js";
3+
4+
describe("base64 helpers", () => {
5+
it("normalizes whitespace and keeps valid base64", () => {
6+
const input = " SGV s bG8= \n";
7+
expect(canonicalizeBase64(input)).toBe("SGVsbG8=");
8+
});
9+
10+
it("rejects invalid base64 characters", () => {
11+
const input = 'SGVsbG8=" onerror="alert(1)';
12+
expect(canonicalizeBase64(input)).toBeUndefined();
13+
});
14+
15+
it("estimates decoded bytes with whitespace", () => {
16+
expect(estimateBase64DecodedBytes("SGV s bG8= \n")).toBe(5);
17+
});
18+
});

src/media/base64.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,17 @@ export function estimateBase64DecodedBytes(base64: string): number {
3535
const estimated = Math.floor((effectiveLen * 3) / 4) - padding;
3636
return Math.max(0, estimated);
3737
}
38+
39+
const BASE64_CHARS_RE = /^[A-Za-z0-9+/]+={0,2}$/;
40+
41+
/**
42+
* Normalize and validate a base64 string.
43+
* Returns canonical base64 (no whitespace) or undefined when invalid.
44+
*/
45+
export function canonicalizeBase64(base64: string): string | undefined {
46+
const cleaned = base64.replace(/\s+/g, "");
47+
if (!cleaned || cleaned.length % 4 !== 0 || !BASE64_CHARS_RE.test(cleaned)) {
48+
return undefined;
49+
}
50+
return cleaned;
51+
}

src/media/input-files.fetch-guard.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,42 @@ describe("base64 size guards", () => {
113113
fromSpy.mockRestore();
114114
});
115115
});
116+
117+
describe("input image base64 validation", () => {
118+
it("rejects malformed base64 payloads", async () => {
119+
await expect(
120+
extractImageContentFromSource(
121+
{
122+
type: "base64",
123+
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2N4j8AAAAASUVORK5CYII=" onerror="alert(1)',
124+
mediaType: "image/png",
125+
},
126+
{
127+
allowUrl: false,
128+
allowedMimes: new Set(["image/png"]),
129+
maxBytes: 1024 * 1024,
130+
maxRedirects: 0,
131+
timeoutMs: 1,
132+
},
133+
),
134+
).rejects.toThrow("invalid 'data' field");
135+
});
136+
137+
it("normalizes whitespace in valid base64 payloads", async () => {
138+
const image = await extractImageContentFromSource(
139+
{
140+
type: "base64",
141+
data: " aGVs bG8= \n",
142+
mediaType: "image/png",
143+
},
144+
{
145+
allowUrl: false,
146+
allowedMimes: new Set(["image/png"]),
147+
maxBytes: 1024 * 1024,
148+
maxRedirects: 0,
149+
timeoutMs: 1,
150+
},
151+
);
152+
expect(image.data).toBe("aGVsbG8=");
153+
});
154+
});

src/media/input-files.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
22
import type { SsrFPolicy } from "../infra/net/ssrf.js";
33
import { logWarn } from "../logger.js";
4-
import { estimateBase64DecodedBytes } from "./base64.js";
4+
import { canonicalizeBase64, estimateBase64DecodedBytes } from "./base64.js";
55
import { readResponseWithLimit } from "./read-response-with-limit.js";
66

77
type CanvasModule = typeof import("@napi-rs/canvas");
@@ -309,17 +309,21 @@ export async function extractImageContentFromSource(
309309
throw new Error("input_image base64 source missing 'data' field");
310310
}
311311
rejectOversizedBase64Payload({ data: source.data, maxBytes: limits.maxBytes, label: "Image" });
312+
const canonicalData = canonicalizeBase64(source.data);
313+
if (!canonicalData) {
314+
throw new Error("input_image base64 source has invalid 'data' field");
315+
}
312316
const mimeType = normalizeMimeType(source.mediaType) ?? "image/png";
313317
if (!limits.allowedMimes.has(mimeType)) {
314318
throw new Error(`Unsupported image MIME type: ${mimeType}`);
315319
}
316-
const buffer = Buffer.from(source.data, "base64");
320+
const buffer = Buffer.from(canonicalData, "base64");
317321
if (buffer.byteLength > limits.maxBytes) {
318322
throw new Error(
319323
`Image too large: ${buffer.byteLength} bytes (limit: ${limits.maxBytes} bytes)`,
320324
);
321325
}
322-
return { type: "image", data: source.data, mimeType };
326+
return { type: "image", data: canonicalData, mimeType };
323327
}
324328

325329
if (source.type === "url" && source.url) {
@@ -362,10 +366,14 @@ export async function extractFileContentFromSource(params: {
362366
throw new Error("input_file base64 source missing 'data' field");
363367
}
364368
rejectOversizedBase64Payload({ data: source.data, maxBytes: limits.maxBytes, label: "File" });
369+
const canonicalData = canonicalizeBase64(source.data);
370+
if (!canonicalData) {
371+
throw new Error("input_file base64 source has invalid 'data' field");
372+
}
365373
const parsed = parseContentType(source.mediaType);
366374
mimeType = parsed.mimeType;
367375
charset = parsed.charset;
368-
buffer = Buffer.from(source.data, "base64");
376+
buffer = Buffer.from(canonicalData, "base64");
369377
} else if (source.type === "url" && source.url) {
370378
if (!limits.allowUrl) {
371379
throw new Error("input_file URL sources are disabled by config");

0 commit comments

Comments
 (0)