fix: render authenticated control ui avatars

This commit is contained in:
Peter Steinberger
2026-04-25 10:46:14 +01:00
committed by GitHub
parent 9c64a0ca23
commit da2c61fe6e
6 changed files with 67 additions and 13 deletions

View File

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

View File

@@ -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/<id>`) still render.
- Avatars and images served under relative paths (for example `/avatars/<id>`) 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.

View File

@@ -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", () => {

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:",
"img-src 'self' data: blob:",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' ws: wss:",
].join("; ");

View File

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

View File

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