From e578521ef4930d02c573fa2d9ef72c4317a34dd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 02:52:33 +0000 Subject: [PATCH] fix(security): harden session export image data-url handling --- CHANGELOG.md | 1 + src/agents/tool-images.test.ts | 18 +++++++++ src/agents/tool-images.ts | 13 ++++++- src/auto-reply/reply/export-html/template.js | 34 ++++++++++++----- src/media/base64.test.ts | 18 +++++++++ src/media/base64.ts | 14 +++++++ src/media/input-files.fetch-guard.test.ts | 39 ++++++++++++++++++++ src/media/input-files.ts | 16 ++++++-- 8 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 src/media/base64.test.ts 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 ( '
' + - images - .map( - (img) => - ``, - ) - .join("") + + images.map((img) => renderDataUrlImage(img, "tool-image")).join("") + "
" ); }; @@ -1315,7 +1331,7 @@ if (images.length > 0) { html += '
'; for (const img of images) { - html += ``; + html += renderDataUrlImage(img, "message-image"); } html += "
"; } diff --git a/src/media/base64.test.ts b/src/media/base64.test.ts new file mode 100644 index 00000000000..7888bea4578 --- /dev/null +++ b/src/media/base64.test.ts @@ -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); + }); +}); diff --git a/src/media/base64.ts b/src/media/base64.ts index 56a8626c37b..aa81ae5d295 100644 --- a/src/media/base64.ts +++ b/src/media/base64.ts @@ -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; +} diff --git a/src/media/input-files.fetch-guard.test.ts b/src/media/input-files.fetch-guard.test.ts index 0b293e5cf42..64f8377bcfd 100644 --- a/src/media/input-files.fetch-guard.test.ts +++ b/src/media/input-files.fetch-guard.test.ts @@ -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="); + }); +}); diff --git a/src/media/input-files.ts b/src/media/input-files.ts index 61fc067ef9b..b6d2aa837aa 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -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");