mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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("; ");
|
||||
|
||||
@@ -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 }>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user