mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
fix: render authenticated control ui avatars
This commit is contained in:
committed by
GitHub
parent
9c64a0ca23
commit
da2c61fe6e
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("; ");
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user