From e9750104b2418bcb2eec7b126b115cae2c1f9c89 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 14:50:04 +0000 Subject: [PATCH] ui: block svg data image opens and harden tests --- .github/workflows/ci.yml | 3 ++ ui/src/ui/open-external-url.test.ts | 44 ++++++++++++++++++- ui/src/ui/open-external-url.ts | 7 ++- .../ui/views/chat-image-open.browser.test.ts | 17 +++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0266c72174..8de4f3882c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,6 +259,9 @@ jobs: - name: Check types and lint and oxfmt run: pnpm check + - name: Enforce safe external URL opening policy + run: pnpm lint:ui:no-raw-window-open + # Report-only dead-code scans. Runs after scope detection and stores machine-readable # results as artifacts for later triage before we enable hard gates. # Temporarily disabled in CI while we process initial findings. diff --git a/ui/src/ui/open-external-url.test.ts b/ui/src/ui/open-external-url.test.ts index 8972516ed57..fb3f15d88d9 100644 --- a/ui/src/ui/open-external-url.test.ts +++ b/ui/src/ui/open-external-url.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from "vitest"; -import { resolveSafeExternalUrl } from "./open-external-url.ts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openExternalUrlSafe, resolveSafeExternalUrl } from "./open-external-url.ts"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); describe("resolveSafeExternalUrl", () => { const baseHref = "https://openclaw.ai/chat"; @@ -38,6 +43,18 @@ describe("resolveSafeExternalUrl", () => { ).toBeNull(); }); + it("rejects SVG data image URLs", () => { + expect( + resolveSafeExternalUrl( + "data:image/svg+xml,", + baseHref, + { + allowDataImage: true, + }, + ), + ).toBeNull(); + }); + it("rejects data image URLs unless explicitly enabled", () => { expect(resolveSafeExternalUrl("data:image/png;base64,iVBORw0KGgo=", baseHref)).toBeNull(); }); @@ -54,3 +71,26 @@ describe("resolveSafeExternalUrl", () => { expect(resolveSafeExternalUrl(" ", baseHref)).toBeNull(); }); }); + +describe("openExternalUrlSafe", () => { + it("nulls opener when window.open returns a proxy-like object", () => { + const openedLikeProxy = { + opener: { postMessage: () => void 0 }, + } as unknown as WindowProxy; + const openMock = vi.fn(() => openedLikeProxy); + vi.stubGlobal("window", { + location: { href: "https://openclaw.ai/chat" }, + open: openMock, + } as unknown as Window & typeof globalThis); + + const opened = openExternalUrlSafe("https://example.com/safe.png"); + + expect(openMock).toHaveBeenCalledWith( + "https://example.com/safe.png", + "_blank", + "noopener,noreferrer", + ); + expect(opened).toBe(openedLikeProxy); + expect(openedLikeProxy.opener).toBeNull(); + }); +}); diff --git a/ui/src/ui/open-external-url.ts b/ui/src/ui/open-external-url.ts index 321e69a71fc..ed5a99c8678 100644 --- a/ui/src/ui/open-external-url.ts +++ b/ui/src/ui/open-external-url.ts @@ -1,5 +1,6 @@ const DATA_URL_PREFIX = "data:"; const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "blob:"]); +const BLOCKED_DATA_IMAGE_MIME_TYPES = new Set(["image/svg+xml"]); function isAllowedDataImageUrl(url: string): boolean { if (!url.toLowerCase().startsWith(DATA_URL_PREFIX)) { @@ -13,7 +14,11 @@ function isAllowedDataImageUrl(url: string): boolean { const metadata = url.slice(DATA_URL_PREFIX.length, commaIndex); const mimeType = metadata.split(";")[0]?.trim().toLowerCase() ?? ""; - return mimeType.startsWith("image/"); + if (!mimeType.startsWith("image/")) { + return false; + } + + return !BLOCKED_DATA_IMAGE_MIME_TYPES.has(mimeType); } export type ResolveSafeExternalUrlOptions = { diff --git a/ui/src/ui/views/chat-image-open.browser.test.ts b/ui/src/ui/views/chat-image-open.browser.test.ts index 60e6df26554..9f2090a139b 100644 --- a/ui/src/ui/views/chat-image-open.browser.test.ts +++ b/ui/src/ui/views/chat-image-open.browser.test.ts @@ -50,4 +50,21 @@ describe("chat image open safety", () => { expect(openSpy).not.toHaveBeenCalled(); }); + + it("does not open SVG data image URLs", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + app.chatMessages = [ + renderAssistantImage("data:image/svg+xml,"), + ]; + await app.updateComplete; + + const image = app.querySelector(".chat-message-image"); + expect(image).not.toBeNull(); + image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(openSpy).not.toHaveBeenCalled(); + }); });