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();
+ });
});