From ebb5680893a44b9ce08c0ce1e610069a7c5c8828 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 13:09:20 +0000 Subject: [PATCH] ui(chat): allowlist image open URLs --- ui/src/ui/chat/grouped-render.ts | 8 +++++- ui/src/ui/chat/image-open.test.ts | 48 +++++++++++++++++++++++++++++++ ui/src/ui/chat/image-open.ts | 39 +++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 ui/src/ui/chat/image-open.test.ts create mode 100644 ui/src/ui/chat/image-open.ts diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 4726596c6e1..e8993f0c29d 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -5,6 +5,7 @@ import { toSanitizedMarkdownHtml } from "../markdown.ts"; import { detectTextDirection } from "../text-direction.ts"; import type { MessageGroup } from "../types/chat-types.ts"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; +import { resolveSafeImageOpenUrl } from "./image-open.ts"; import { extractTextCached, extractThinkingCached, @@ -201,7 +202,12 @@ function renderMessageImages(images: ImageBlock[]) { } const openImage = (url: string) => { - const opened = window.open(url, "_blank", "noopener,noreferrer"); + const safeUrl = resolveSafeImageOpenUrl(url, window.location.href); + if (!safeUrl) { + return; + } + + const opened = window.open(safeUrl, "_blank", "noopener,noreferrer"); if (opened) { opened.opener = null; } diff --git a/ui/src/ui/chat/image-open.test.ts b/ui/src/ui/chat/image-open.test.ts new file mode 100644 index 00000000000..180e909bb09 --- /dev/null +++ b/ui/src/ui/chat/image-open.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { resolveSafeImageOpenUrl } from "./image-open.ts"; + +describe("resolveSafeImageOpenUrl", () => { + const baseHref = "https://openclaw.ai/chat"; + + it("allows absolute https URLs", () => { + expect(resolveSafeImageOpenUrl("https://example.com/a.png?x=1#y", baseHref)).toBe( + "https://example.com/a.png?x=1#y", + ); + }); + + it("allows relative URLs resolved against the current origin", () => { + expect(resolveSafeImageOpenUrl("/assets/pic.png", baseHref)).toBe( + "https://openclaw.ai/assets/pic.png", + ); + }); + + it("allows blob URLs", () => { + expect(resolveSafeImageOpenUrl("blob:https://openclaw.ai/abc-123", baseHref)).toBe( + "blob:https://openclaw.ai/abc-123", + ); + }); + + it("allows data image URLs", () => { + expect(resolveSafeImageOpenUrl("data:image/png;base64,iVBORw0KGgo=", baseHref)).toBe( + "data:image/png;base64,iVBORw0KGgo=", + ); + }); + + it("rejects non-image data URLs", () => { + expect( + resolveSafeImageOpenUrl("data:text/html,", baseHref), + ).toBeNull(); + }); + + it("rejects javascript URLs", () => { + expect(resolveSafeImageOpenUrl("javascript:alert(1)", baseHref)).toBeNull(); + }); + + it("rejects file URLs", () => { + expect(resolveSafeImageOpenUrl("file:///tmp/x.png", baseHref)).toBeNull(); + }); + + it("rejects empty values", () => { + expect(resolveSafeImageOpenUrl(" ", baseHref)).toBeNull(); + }); +}); diff --git a/ui/src/ui/chat/image-open.ts b/ui/src/ui/chat/image-open.ts new file mode 100644 index 00000000000..fd096c67184 --- /dev/null +++ b/ui/src/ui/chat/image-open.ts @@ -0,0 +1,39 @@ +const DATA_URL_PREFIX = "data:"; +const ALLOWED_OPEN_PROTOCOLS = new Set(["http:", "https:", "blob:"]); + +function isAllowedDataImageUrl(url: string): boolean { + if (!url.toLowerCase().startsWith(DATA_URL_PREFIX)) { + return false; + } + + const commaIndex = url.indexOf(","); + if (commaIndex < DATA_URL_PREFIX.length) { + return false; + } + + const metadata = url.slice(DATA_URL_PREFIX.length, commaIndex); + const mimeType = metadata.split(";")[0]?.trim().toLowerCase() ?? ""; + return mimeType.startsWith("image/"); +} + +export function resolveSafeImageOpenUrl(rawUrl: string, baseHref: string): string | null { + const candidate = rawUrl.trim(); + if (!candidate) { + return null; + } + + if (isAllowedDataImageUrl(candidate)) { + return candidate; + } + + if (candidate.toLowerCase().startsWith(DATA_URL_PREFIX)) { + return null; + } + + try { + const parsed = new URL(candidate, baseHref); + return ALLOWED_OPEN_PROTOCOLS.has(parsed.protocol.toLowerCase()) ? parsed.toString() : null; + } catch { + return null; + } +}