From e6e83e6ccf2ee82b12f5193ca7fe3b2527404ffa Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 21 Apr 2026 10:30:32 -0600 Subject: [PATCH] fix(control-ui): block remote image loads (#69773) * fix(control-ui): block remote image loads * fix(control-ui): reject protocol-relative avatar URLs * docs(changelog): note control-ui image CSP tightening (#69773) --- CHANGELOG.md | 1 + src/gateway/control-ui-csp.test.ts | 6 ++++++ src/gateway/control-ui-csp.ts | 2 +- ui/src/ui/app-chat.test.ts | 13 +++++++++++++ ui/src/ui/app-chat.ts | 3 ++- ui/src/ui/app-render.ts | 9 ++++----- ui/src/ui/chat/grouped-render.test.ts | 20 ++++++++++++++++++++ ui/src/ui/chat/grouped-render.ts | 6 ++---- ui/src/ui/views/agents-utils.test.ts | 16 ++++++++++++++++ ui/src/ui/views/agents-utils.ts | 8 ++++++-- 10 files changed, 71 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3817d3fd02..fcf6ad60478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Auth/commands: require owner identity (an owner-candidate match or internal `operator.admin`) for owner-enforced commands instead of treating wildcard channel `allowFrom` or empty owner-candidate lists as sufficient, so non-owner senders can no longer reach owner-only commands through a permissive fallback when `enforceOwnerForCommands=true` and `commands.ownerAllowFrom` is unset. (#69774) Thanks @drobison00. +- Control UI/CSP: tighten `img-src` to `'self' data:` only, and make Control UI avatar helpers drop remote `http(s)` and protocol-relative URLs so the UI falls back to the built-in logo/badge instead of issuing arbitrary remote image fetches. Same-origin avatar routes (relative paths) and `data:image/...` avatars still render. (#69773) ## 2026.4.20 diff --git a/src/gateway/control-ui-csp.test.ts b/src/gateway/control-ui-csp.test.ts index 56b7ce2d593..c2cd0364a82 100644 --- a/src/gateway/control-ui-csp.test.ts +++ b/src/gateway/control-ui-csp.test.ts @@ -17,6 +17,12 @@ describe("buildControlUiCspHeader", () => { expect(csp).toContain("font-src 'self' https://fonts.gstatic.com"); }); + it("limits image loading to same-origin and data URLs", () => { + const csp = buildControlUiCspHeader(); + expect(csp).toContain("img-src 'self' data:"); + expect(csp).not.toContain("img-src 'self' data: https:"); + }); + it("includes inline script hashes in script-src when provided", () => { const csp = buildControlUiCspHeader({ inlineScriptHashes: ["sha256-abc123"], diff --git a/src/gateway/control-ui-csp.ts b/src/gateway/control-ui-csp.ts index efe38a54622..5f153176a13 100644 --- a/src/gateway/control-ui-csp.ts +++ b/src/gateway/control-ui-csp.ts @@ -44,7 +44,7 @@ export function buildControlUiCspHeader(opts?: { inlineScriptHashes?: string[] } "frame-ancestors 'none'", scriptSrc, "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", - "img-src 'self' data: https:", + "img-src 'self' data:", "font-src 'self' https://fonts.gstatic.com", "connect-src 'self' ws: wss:", ].join("; "); diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index edd3e6186bf..9b339de2c75 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -130,6 +130,19 @@ describe("refreshChatAvatar", () => { expect(host.chatAvatarUrl).toBeNull(); }); + it("drops remote avatar metadata so the control UI can rely on same-origin images only", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ avatarUrl: "https://example.com/avatar.png" }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const host = makeHost({ basePath: "", sessionKey: "agent:main" }); + await refreshChatAvatar(host); + + expect(host.chatAvatarUrl).toBeNull(); + }); + it("ignores stale avatar responses after switching sessions", async () => { const mainRequest = createDeferred<{ avatarUrl?: string }>(); const opsRequest = createDeferred<{ avatarUrl?: string }>(); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 7ef248f05fd..0c800fa3a90 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -21,6 +21,7 @@ import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts"; import type { SessionsListResult } from "./types.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; +import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts"; export type ChatHost = { client: GatewayBrowserClient | null; @@ -557,7 +558,7 @@ export async function refreshChatAvatar(host: ChatHost) { return; } const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : ""; - host.chatAvatarUrl = avatarUrl || null; + host.chatAvatarUrl = avatarUrl && isRenderableControlUiAvatarUrl(avatarUrl) ? avatarUrl : null; } catch { if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) { host.chatAvatarUrl = null; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 1b1033e9afa..2955d0c411d 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -118,10 +118,11 @@ import { updateSkillEnabled, } from "./controllers/skills.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; -import "./components/dashboard-header.ts"; import { icons } from "./icons.ts"; +import "./components/dashboard-header.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts"; +import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts"; import { agentLogoUrl } from "./views/agents-utils.ts"; import { resolveAgentConfig, @@ -314,8 +315,6 @@ function dismissUpdateBanner(updateAvailable: unknown) { } } -const AVATAR_DATA_RE = /^data:/i; -const AVATAR_HTTP_RE = /^https?:\/\//i; const COMMUNICATION_SECTION_KEYS = ["channels", "messages", "broadcast", "talk", "audio"] as const; const APPEARANCE_SECTION_KEYS = ["__appearance__", "ui", "wizard"] as const; const AUTOMATION_SECTION_KEYS = [ @@ -413,10 +412,10 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { if (!candidate) { return undefined; } - if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) { + if (isRenderableControlUiAvatarUrl(candidate)) { return candidate; } - return identity?.avatarUrl; + return undefined; } // ── Quick Settings data extraction helpers ── diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 07ff8d252e1..c4bc0d431dd 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -17,6 +17,8 @@ vi.mock("../markdown.ts", () => ({ vi.mock("../views/agents-utils.ts", () => ({ agentLogoUrl: () => "/openclaw-logo.svg", + isRenderableControlUiAvatarUrl: (value: string) => + /^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")), })); vi.mock("./speech.ts", () => ({ @@ -186,6 +188,24 @@ describe("grouped chat rendering", () => { expect(assistantConfirm?.classList.contains("chat-delete-confirm--right")).toBe(true); }); + it("falls back to the local logo when the assistant avatar is a remote URL", () => { + const container = document.createElement("div"); + + renderAssistantMessage( + container, + { + role: "assistant", + content: "hello", + timestamp: 1000, + }, + { assistantAvatar: "https://example.com/avatar.png" }, + ); + + const avatar = container.querySelector(".chat-avatar.assistant"); + expect(avatar).not.toBeNull(); + expect(avatar?.getAttribute("src")).toBe("/openclaw-logo.svg"); + }); + it("keeps inline tool cards collapsed by default and renders expanded state", () => { const container = document.createElement("div"); const message = { diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index aec7c5cc59c..ee945ff24b7 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -14,7 +14,7 @@ import type { NormalizedMessage, ToolCard, } from "../types/chat-types.ts"; -import { agentLogoUrl } from "../views/agents-utils.ts"; +import { agentLogoUrl, isRenderableControlUiAvatarUrl } from "../views/agents-utils.ts"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; import { extractTextCached, @@ -665,9 +665,7 @@ function renderAvatar( } function isAvatarUrl(value: string): boolean { - return ( - /^https?:\/\//i.test(value) || /^data:image\//i.test(value) || value.startsWith("/") // Relative paths from avatar endpoint - ); + return isRenderableControlUiAvatarUrl(value); } function resolveRenderableMessageImages( diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index 3bba0c43fa7..2439844af6b 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/ui/views/agents-utils.test.ts @@ -127,6 +127,22 @@ describe("resolveAgentAvatarUrl", () => { ).toBe("/avatar/main"); }); + it("ignores remote http avatars so the control UI falls back to a local badge", () => { + expect( + resolveAgentAvatarUrl({ + identity: { avatarUrl: "https://example.com/avatar.png" }, + }), + ).toBeNull(); + }); + + it("ignores protocol-relative avatars so the control UI cannot be tricked into a cross-origin fetch", () => { + expect( + resolveAgentAvatarUrl({ + identity: { avatarUrl: "//evil.example/avatar.png" }, + }), + ).toBeNull(); + }); + it("returns null for initials or emoji avatar values without a URL", () => { expect(resolveAgentAvatarUrl({ identity: { avatar: "A" } })).toBeNull(); expect(resolveAgentAvatarUrl({ identity: { avatar: "🦞" } })).toBeNull(); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index a92fd057097..07c26133d48 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -198,7 +198,11 @@ export function normalizeAgentLabel(agent: { ); } -const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|\/)/i; +const CONTROL_UI_AVATAR_URL_RE = /^(data:image\/|\/(?!\/))/i; + +export function isRenderableControlUiAvatarUrl(value: string): boolean { + return CONTROL_UI_AVATAR_URL_RE.test(value); +} export function resolveAgentAvatarUrl( agent: { identity?: { avatar?: string; avatarUrl?: string } }, @@ -213,7 +217,7 @@ export function resolveAgentAvatarUrl( if (!candidate) { continue; } - if (AVATAR_URL_RE.test(candidate)) { + if (isRenderableControlUiAvatarUrl(candidate)) { return candidate; } }