diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c1aa1c2589..a0f15aaa71f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/src/agents/tool-images.test.ts b/src/agents/tool-images.test.ts
index 6de86b0e4bd..83c6a0adbba 100644
--- a/src/agents/tool-images.test.ts
+++ b/src/agents/tool-images.test.ts
@@ -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",
+ },
+ ]);
+ });
});
diff --git a/src/agents/tool-images.ts b/src/agents/tool-images.ts
index a72fed30c28..e2019570a31 100644
--- a/src/agents/tool-images.ts
+++ b/src/agents/tool-images.ts
@@ -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,
diff --git a/src/auto-reply/reply/export-html/template.js b/src/auto-reply/reply/export-html/template.js
index 318751fde53..565eeda7f65 100644
--- a/src/auto-reply/reply/export-html/template.js
+++ b/src/auto-reply/reply/export-html/template.js
@@ -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 ``;
+ }
/**
* Truncate string to maxLen chars, append "..." if truncated.
*/
@@ -1037,12 +1058,7 @@
}
return (
'