diff --git a/CHANGELOG.md b/CHANGELOG.md index e19d315fa35..68383b7c69a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ Docs: https://docs.openclaw.ai - Diagnostics: emit structured tool execution diagnostic events with trace context, timing, and redacted error metadata. Thanks @vincentkoc. - Diagnostics: emit structured run and model-call diagnostic events with trace context, duration, and non-message error metadata. Thanks @vincentkoc. - Control UI/chat: add a Steer action on queued messages so a browser follow-up can be injected into the active run without retyping it. Thanks @steipete. +- Control UI/avatars: render authenticated assistant avatar routes through local blob URLs and allow those managed blobs in the dashboard CSP, restoring configured chat avatars. Fixes #71422. Thanks @blaspat. - Control UI/Talk: add browser WebRTC realtime voice sessions backed by OpenAI Realtime, with Gateway-minted ephemeral client secrets and `openclaw_agent_consult` handoff to the full OpenClaw agent. Thanks @steipete. - Plugin SDK/Codex harness: add provider-owned transport/auth/follow-up seams and harness result classification so Codex-style runtimes can participate in fallback policy without core special-casing. (#70772) Thanks @100yenadmin. - Codex harness: bridge Codex-native tool hooks into OpenClaw plugin hooks and approvals, with bounded relay payloads and approval spam protection. (#71008) Thanks @pashpashpash. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 2a54872c27c..82521d904d9 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -324,12 +324,13 @@ See [Tailscale](/gateway/tailscale) for HTTPS setup guidance. ## Content Security Policy -The Control UI ships with a tight `img-src` policy: only **same-origin** assets and `data:` URLs are allowed. Remote `http(s)` and protocol-relative image URLs are rejected by the browser and do not issue network fetches. +The Control UI ships with a tight `img-src` policy: only **same-origin** assets, `data:` URLs, and locally generated `blob:` URLs are allowed. Remote `http(s)` and protocol-relative image URLs are rejected by the browser and do not issue network fetches. What this means in practice: -- Avatars and images served under relative paths (for example `/avatars/`) still render. +- Avatars and images served under relative paths (for example `/avatars/`) still render, including authenticated avatar routes that the UI fetches and converts into local `blob:` URLs. - Inline `data:image/...` URLs still render (useful for in-protocol payloads). +- Local `blob:` URLs created by the Control UI still render. - Remote avatar URLs emitted by channel metadata are stripped at the Control UI's avatar helpers and replaced with the built-in logo/badge, so a compromised or malicious channel cannot force arbitrary remote image fetches from an operator browser. You do not need to change anything to get this behavior — it is always on and not configurable. diff --git a/src/gateway/control-ui-csp.test.ts b/src/gateway/control-ui-csp.test.ts index c2cd0364a82..0a8cd209ed2 100644 --- a/src/gateway/control-ui-csp.test.ts +++ b/src/gateway/control-ui-csp.test.ts @@ -17,10 +17,10 @@ describe("buildControlUiCspHeader", () => { expect(csp).toContain("font-src 'self' https://fonts.gstatic.com"); }); - it("limits image loading to same-origin and data URLs", () => { + it("limits image loading to same-origin, data, and managed blob URLs", () => { const csp = buildControlUiCspHeader(); - expect(csp).toContain("img-src 'self' data:"); - expect(csp).not.toContain("img-src 'self' data: https:"); + expect(csp).toContain("img-src 'self' data: blob:"); + expect(csp).not.toContain("img-src 'self' data: blob: https:"); }); it("includes inline script hashes in script-src when provided", () => { diff --git a/src/gateway/control-ui-csp.ts b/src/gateway/control-ui-csp.ts index 5f153176a13..1131e95d41a 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:", + "img-src 'self' data: blob:", "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 a9516ae44fe..69d4efa8a96 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -104,9 +104,30 @@ describe("refreshChatAvatar", () => { }); it("uses a route-relative avatar endpoint before basePath bootstrap finishes", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ avatarUrl: "/avatar/main" }), + const createObjectURL = vi.fn(() => "blob:local-avatar"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal( + "URL", + class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }, + ); + const fetchMock = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input); + if (url === "/avatar/main?meta=1") { + return Promise.resolve({ + ok: true, + json: async () => ({ avatarUrl: "/avatar/main" }), + }); + } + if (url === "/avatar/main") { + return Promise.resolve({ + ok: true, + blob: async () => new Blob(["avatar"]), + }); + } + throw new Error(`Unexpected avatar URL: ${url}`); }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); @@ -117,7 +138,17 @@ describe("refreshChatAvatar", () => { "/avatar/main?meta=1", expect.objectContaining({ method: "GET" }), ); - expect(host.chatAvatarUrl).toBe("/avatar/main"); + expect(fetchMock).toHaveBeenCalledWith( + "/avatar/main", + expect.objectContaining({ method: "GET" }), + ); + const avatarFetchInit = ( + fetchMock.mock.calls as Array<[string | URL | Request, RequestInit?]> + )[1]?.[1]; + expect(avatarFetchInit).not.toHaveProperty("headers"); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).not.toHaveBeenCalled(); + expect(host.chatAvatarUrl).toBe("blob:local-avatar"); }); it("prefers the paired device token for avatar metadata and local avatar URLs", async () => { @@ -261,6 +292,15 @@ describe("refreshChatAvatar", () => { }); it("ignores stale avatar responses after switching sessions", async () => { + const createObjectURL = vi.fn(() => "blob:ops-avatar"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal( + "URL", + class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }, + ); const mainRequest = createDeferred<{ avatarUrl?: string }>(); const opsRequest = createDeferred<{ avatarUrl?: string }>(); const fetchMock = vi.fn((input: string | URL | Request) => { @@ -277,6 +317,12 @@ describe("refreshChatAvatar", () => { json: async () => opsRequest.promise, }); } + if (url === "/avatar/ops") { + return Promise.resolve({ + ok: true, + blob: async () => new Blob(["avatar"]), + }); + } throw new Error(`Unexpected avatar URL: ${url}`); }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); @@ -294,7 +340,8 @@ describe("refreshChatAvatar", () => { opsRequest.resolve({ avatarUrl: "/avatar/ops" }); await secondRefresh; - expect(host.chatAvatarUrl).toBe("/avatar/ops"); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(host.chatAvatarUrl).toBe("blob:ops-avatar"); expect(fetchMock).toHaveBeenNthCalledWith( 1, "/avatar/main?meta=1", @@ -305,6 +352,11 @@ describe("refreshChatAvatar", () => { "/avatar/ops?meta=1", expect.objectContaining({ method: "GET" }), ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "/avatar/ops", + expect.objectContaining({ method: "GET" }), + ); }); }); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index c40872e2518..55eefff0a1a 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -653,13 +653,13 @@ export async function refreshChatAvatar(host: ChatHost) { clearChatAvatarUrl(host); return; } - if (!authHeader || !isLocalControlUiAvatarUrl(avatarUrl)) { + if (!isLocalControlUiAvatarUrl(avatarUrl)) { setChatAvatarUrl(host, avatarUrl); return; } const avatarRes = await fetch(avatarUrl, { method: "GET", - headers: { Authorization: authHeader }, + ...(headers ? { headers } : {}), }); if (!avatarRes.ok) { if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) {