ui(chat): allowlist image open URLs

This commit is contained in:
Shakker
2026-02-24 13:09:20 +00:00
committed by Peter Steinberger
parent 370d115549
commit ebb5680893
3 changed files with 94 additions and 1 deletions

View File

@@ -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;
}

View File

@@ -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,<script>alert(1)</script>", 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();
});
});

View File

@@ -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;
}
}