ui: block svg data image opens and harden tests

This commit is contained in:
Shakker
2026-02-24 14:50:04 +00:00
committed by Shakker
parent 9ef0fc2ff8
commit e9750104b2
4 changed files with 68 additions and 3 deletions

View File

@@ -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.

View File

@@ -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,<svg xmlns='http://www.w3.org/2000/svg' />",
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();
});
});

View File

@@ -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 = {

View File

@@ -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,<svg xmlns='http://www.w3.org/2000/svg' />"),
];
await app.updateComplete;
const image = app.querySelector<HTMLImageElement>(".chat-message-image");
expect(image).not.toBeNull();
image?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(openSpy).not.toHaveBeenCalled();
});
});