mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(security): harden session export image data-url handling
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
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