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)
This commit is contained in:
Devin Robison
2026-04-21 10:30:32 -06:00
committed by GitHub
parent 2aa93d44a1
commit e6e83e6ccf
10 changed files with 71 additions and 13 deletions

View File

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

View File

@@ -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"],

View File

@@ -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("; ");

View File

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

View File

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

View File

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

View File

@@ -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<HTMLImageElement>(".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 = {

View File

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

View File

@@ -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();

View File

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