fix(gateway): require auth for control UI avatar route (#69775)

* fix(gateway): require auth for control UI avatar route

* chore: add changelog for control UI avatar auth

* fix(control-ui): honor device auth for avatar urls

* fix(control-ui): avoid query tokens for avatar auth

* fix(control-ui): render authenticated avatar blob URLs in chat views

* fix(control-ui): restore normalizeOptionalString import in render helpers
This commit is contained in:
Devin Robison
2026-04-21 13:51:03 -06:00
committed by GitHub
parent 6b185e2849
commit 2ce16e558e
15 changed files with 513 additions and 87 deletions

View File

@@ -5,8 +5,8 @@ import {
waitWhatsAppLogin,
type ChannelsState,
} from "./controllers/channels.ts";
import { resolveControlUiAuthHeader } from "./control-ui-auth.ts";
import { loadConfig, saveConfig, type ConfigState } from "./controllers/config.ts";
import { normalizeOptionalString } from "./string-coerce.ts";
import type { NostrProfile } from "./types.ts";
import { createNostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
@@ -78,24 +78,8 @@ function buildNostrProfileUrl(accountId: string, suffix = ""): string {
return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`;
}
function resolveGatewayHttpAuthHeader(host: ChannelsActionHost): string | null {
const deviceToken = normalizeOptionalString(host.hello?.auth?.deviceToken);
if (deviceToken) {
return `Bearer ${deviceToken}`;
}
const token = normalizeOptionalString(host.settings.token);
if (token) {
return `Bearer ${token}`;
}
const password = normalizeOptionalString(host.password);
if (password) {
return `Bearer ${password}`;
}
return null;
}
function buildGatewayHttpHeaders(host: ChannelsActionHost): Record<string, string> {
const authorization = resolveGatewayHttpAuthHeader(host);
const authorization = resolveControlUiAuthHeader(host);
return authorization ? { Authorization: authorization } : {};
}

View File

@@ -107,12 +107,122 @@ describe("refreshChatAvatar", () => {
await refreshChatAvatar(host);
expect(fetchMock).toHaveBeenCalledWith(
"avatar/main?meta=1",
"/avatar/main?meta=1",
expect.objectContaining({ method: "GET" }),
);
expect(host.chatAvatarUrl).toBe("/avatar/main");
});
it("prefers the paired device token for avatar metadata and local avatar URLs", async () => {
const createObjectURL = vi.fn(() => "blob:device-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 === "/openclaw/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);
const host = makeHost({
basePath: "/openclaw/",
sessionKey: "agent:main",
settings: { token: "session-token" },
password: "shared-password",
hello: { auth: { deviceToken: "device-token" } } as ChatHost["hello"],
});
await refreshChatAvatar(host);
expect(fetchMock).toHaveBeenCalledWith(
"/openclaw/avatar/main?meta=1",
expect.objectContaining({
method: "GET",
headers: { Authorization: "Bearer device-token" },
}),
);
expect(fetchMock).toHaveBeenCalledWith(
"/avatar/main",
expect.objectContaining({
method: "GET",
headers: { Authorization: "Bearer device-token" },
}),
);
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).not.toHaveBeenCalled();
expect(host.chatAvatarUrl).toBe("blob:device-avatar");
});
it("fetches local avatars through Authorization headers instead of tokenized URLs", async () => {
const createObjectURL = vi.fn(() => "blob:session-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 === "/openclaw/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);
const host = makeHost({
basePath: "/openclaw/",
sessionKey: "agent:main",
settings: { token: "session-token" },
});
await refreshChatAvatar(host);
expect(fetchMock).toHaveBeenCalledWith(
"/openclaw/avatar/main?meta=1",
expect.objectContaining({
method: "GET",
headers: { Authorization: "Bearer session-token" },
}),
);
expect(fetchMock).toHaveBeenCalledWith(
"/avatar/main",
expect.objectContaining({
method: "GET",
headers: { Authorization: "Bearer session-token" },
}),
);
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).not.toHaveBeenCalled();
expect(host.chatAvatarUrl).toBe("blob:session-avatar");
});
it("keeps mounted dashboard avatar endpoints under the normalized base path", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
@@ -148,13 +258,13 @@ describe("refreshChatAvatar", () => {
const opsRequest = createDeferred<{ avatarUrl?: string }>();
const fetchMock = vi.fn((input: string | URL | Request) => {
const url = requestUrl(input);
if (url === "avatar/main?meta=1") {
if (url === "/avatar/main?meta=1") {
return Promise.resolve({
ok: true,
json: async () => mainRequest.promise,
});
}
if (url === "avatar/ops?meta=1") {
if (url === "/avatar/ops?meta=1") {
return Promise.resolve({
ok: true,
json: async () => opsRequest.promise,
@@ -180,12 +290,12 @@ describe("refreshChatAvatar", () => {
expect(host.chatAvatarUrl).toBe("/avatar/ops");
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"avatar/main?meta=1",
"/avatar/main?meta=1",
expect.objectContaining({ method: "GET" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"avatar/ops?meta=1",
"/avatar/ops?meta=1",
expect.objectContaining({ method: "GET" }),
);
});

View File

@@ -13,6 +13,7 @@ import {
} from "./controllers/chat.ts";
import { loadModels } from "./controllers/models.ts";
import { loadSessions, type SessionsState } from "./controllers/sessions.ts";
import { resolveControlUiAuthHeader } from "./control-ui-auth.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import { normalizeBasePath } from "./navigation.ts";
import { parseAgentSessionKey } from "./session-key.ts";
@@ -36,6 +37,8 @@ export type ChatHost = {
lastError?: string | null;
sessionKey: string;
basePath: string;
settings?: { token?: string | null };
password?: string | null;
hello: GatewayHelloOk | null;
chatAvatarUrl: string | null;
chatSideResult?: ChatSideResult | null;
@@ -497,6 +500,8 @@ type SessionDefaultsSnapshot = {
defaultAgentId?: string;
};
const chatAvatarObjectUrls = new WeakMap<object, string>();
function beginChatAvatarRequest(host: ChatHost): number {
const key = host as object;
const nextVersion = (chatAvatarRequestVersions.get(key) ?? 0) + 1;
@@ -525,12 +530,43 @@ function resolveAgentIdForSession(host: ChatHost): string | null {
function buildAvatarMetaUrl(basePath: string, agentId: string): string {
const base = normalizeBasePath(basePath);
const encoded = encodeURIComponent(agentId);
return base ? `${base}/avatar/${encoded}?meta=1` : `avatar/${encoded}?meta=1`;
return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`;
}
function clearChatAvatarUrl(host: ChatHost) {
const key = host as object;
const previousBlobUrl = chatAvatarObjectUrls.get(key);
if (previousBlobUrl) {
URL.revokeObjectURL(previousBlobUrl);
chatAvatarObjectUrls.delete(key);
}
host.chatAvatarUrl = null;
}
function setChatAvatarUrl(host: ChatHost, nextUrl: string | null) {
const key = host as object;
const previousBlobUrl = chatAvatarObjectUrls.get(key);
if (previousBlobUrl && previousBlobUrl !== nextUrl) {
URL.revokeObjectURL(previousBlobUrl);
chatAvatarObjectUrls.delete(key);
}
if (nextUrl?.startsWith("blob:")) {
chatAvatarObjectUrls.set(key, nextUrl);
}
host.chatAvatarUrl = nextUrl;
}
function buildControlUiAuthHeaders(authHeader: string | null): Record<string, string> | undefined {
return authHeader ? { Authorization: authHeader } : undefined;
}
function isLocalControlUiAvatarUrl(avatarUrl: string): boolean {
return avatarUrl.startsWith("/");
}
export async function refreshChatAvatar(host: ChatHost) {
if (!host.connected) {
host.chatAvatarUrl = null;
clearChatAvatarUrl(host);
return;
}
const sessionKey = host.sessionKey;
@@ -538,19 +574,21 @@ export async function refreshChatAvatar(host: ChatHost) {
const agentId = resolveAgentIdForSession(host);
if (!agentId) {
if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) {
host.chatAvatarUrl = null;
clearChatAvatarUrl(host);
}
return;
}
host.chatAvatarUrl = null;
clearChatAvatarUrl(host);
const authHeader = resolveControlUiAuthHeader(host);
const headers = buildControlUiAuthHeaders(authHeader);
const url = buildAvatarMetaUrl(host.basePath, agentId);
try {
const res = await fetch(url, { method: "GET" });
const res = await fetch(url, { method: "GET", ...(headers ? { headers } : {}) });
if (!shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) {
return;
}
if (!res.ok) {
host.chatAvatarUrl = null;
clearChatAvatarUrl(host);
return;
}
const data = (await res.json()) as { avatarUrl?: unknown };
@@ -558,10 +596,30 @@ export async function refreshChatAvatar(host: ChatHost) {
return;
}
const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : "";
host.chatAvatarUrl = avatarUrl && isRenderableControlUiAvatarUrl(avatarUrl) ? avatarUrl : null;
if (!avatarUrl || !isRenderableControlUiAvatarUrl(avatarUrl)) {
clearChatAvatarUrl(host);
return;
}
if (!authHeader || !isLocalControlUiAvatarUrl(avatarUrl)) {
setChatAvatarUrl(host, avatarUrl);
return;
}
const avatarRes = await fetch(avatarUrl, { method: "GET", headers: { Authorization: authHeader } });
if (!avatarRes.ok) {
if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) {
clearChatAvatarUrl(host);
}
return;
}
const blobUrl = URL.createObjectURL(await avatarRes.blob());
if (!shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) {
URL.revokeObjectURL(blobUrl);
return;
}
setChatAvatarUrl(host, blobUrl);
} catch {
if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) {
host.chatAvatarUrl = null;
clearChatAvatarUrl(host);
}
}
}

View File

@@ -178,9 +178,20 @@ describe("parseSessionKey", () => {
});
describe("resolveAssistantAttachmentAuthToken", () => {
it("prefers the paired device token when present", () => {
expect(
resolveAssistantAttachmentAuthToken({
hello: { auth: { deviceToken: "device-token" } } as AppViewState["hello"],
settings: { token: "session-token" } as AppViewState["settings"],
password: "shared-password",
}),
).toBe("device-token");
});
it("prefers the explicit gateway token when present", () => {
expect(
resolveAssistantAttachmentAuthToken({
hello: null,
settings: { token: "session-token" } as AppViewState["settings"],
password: "shared-password",
}),
@@ -190,6 +201,7 @@ describe("resolveAssistantAttachmentAuthToken", () => {
it("falls back to the shared password when token is blank", () => {
expect(
resolveAssistantAttachmentAuthToken({
hello: null,
settings: { token: " " } as AppViewState["settings"],
password: "shared-password",
}),
@@ -199,6 +211,7 @@ describe("resolveAssistantAttachmentAuthToken", () => {
it("returns null when neither auth secret is available", () => {
expect(
resolveAssistantAttachmentAuthToken({
hello: null,
settings: { token: "" } as AppViewState["settings"],
password: " ",
}),

View File

@@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import { t } from "../i18n/index.ts";
import { refreshChat, refreshChatAvatar } from "./app-chat.ts";
import { resolveControlUiAuthToken } from "./control-ui-auth.ts";
import { syncUrlWithSessionKey } from "./app-settings.ts";
import type { AppViewState } from "./app-view-state.ts";
import {
@@ -43,12 +44,8 @@ type ChatRefreshHost = AppViewState & {
updateComplete?: Promise<unknown>;
};
export function resolveAssistantAttachmentAuthToken(
state: Pick<AppViewState, "settings" | "password">,
) {
return (
normalizeOptionalString(state.settings.token) ?? normalizeOptionalString(state.password) ?? null
);
export function resolveAssistantAttachmentAuthToken(state: Pick<AppViewState, "hello" | "settings" | "password">) {
return resolveControlUiAuthToken(state);
}
function resolveSidebarChatSessionKey(state: AppViewState): string {

View File

@@ -144,6 +144,24 @@ afterEach(() => {
});
describe("grouped chat rendering", () => {
it("falls back to the logo while authenticated avatar routes are loading", () => {
const container = document.createElement("div");
renderAssistantMessage(
container,
{
role: "assistant",
content: [{ type: "text", text: "Hello" }],
},
{
assistantAvatar: "/avatar/main",
assistantAttachmentAuthToken: "session-token",
},
);
const img = container.querySelector("img.chat-avatar");
expect(img?.getAttribute("src")).toBe("/openclaw-logo.svg");
});
it("positions delete confirm by message side", () => {
const renderDeletable = (role: "user" | "assistant") => {
const container = document.createElement("div");

View File

@@ -184,10 +184,14 @@ function extractImages(message: unknown): ImageBlock[] {
return images;
}
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity, basePath?: string) {
export function renderReadingIndicatorGroup(
assistant?: AssistantIdentity,
basePath?: string,
authToken?: string | null,
) {
return html`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant, basePath)}
${renderAvatar("assistant", assistant, basePath, authToken)}
<div class="chat-group-messages">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
@@ -205,6 +209,7 @@ export function renderStreamingGroup(
onOpenSidebar?: (content: SidebarContent) => void,
assistant?: AssistantIdentity,
basePath?: string,
authToken?: string | null,
) {
const timestamp = new Date(startedAt).toLocaleTimeString([], {
hour: "numeric",
@@ -214,7 +219,7 @@ export function renderStreamingGroup(
return html`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant, basePath)}
${renderAvatar("assistant", assistant, basePath, authToken)}
<div class="chat-group-messages">
${renderGroupedMessage(
{
@@ -295,6 +300,7 @@ export function renderMessageGroup(
avatar: opts.assistantAvatar ?? null,
},
opts.basePath,
opts.assistantAttachmentAuthToken,
)}
<div class="chat-group-messages">
${group.messages.map((item, index) =>
@@ -586,6 +592,7 @@ function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
basePath?: string,
authToken?: string | null,
) {
const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant";
@@ -638,6 +645,13 @@ function renderAvatar(
if (assistantAvatar && normalized === "assistant") {
if (isAvatarUrl(assistantAvatar)) {
if (authToken?.trim() && assistantAvatar.startsWith("/")) {
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${agentLogoUrl(basePath ?? "")}"
alt="${assistantName}"
/>`;
}
return html`<img
class="chat-avatar ${className}"
src="${assistantAvatar}"

View File

@@ -0,0 +1,21 @@
import { normalizeOptionalString } from "./string-coerce.ts";
type ControlUiAuthSource = {
hello?: { auth?: { deviceToken?: string | null } | null } | null;
settings?: { token?: string | null } | null;
password?: string | null;
};
export function resolveControlUiAuthToken(source: ControlUiAuthSource): string | null {
return (
normalizeOptionalString(source.hello?.auth?.deviceToken) ??
normalizeOptionalString(source.settings?.token) ??
normalizeOptionalString(source.password) ??
null
);
}
export function resolveControlUiAuthHeader(source: ControlUiAuthSource): string | null {
const token = resolveControlUiAuthToken(source);
return token ? `Bearer ${token}` : null;
}

View File

@@ -4,6 +4,7 @@ import {
buildAgentContext,
resolveConfiguredCronModelSuggestions,
resolveAgentAvatarUrl,
resolveChatAvatarRenderUrl,
resolveEffectiveModelFallbacks,
sortLocaleStrings,
} from "./agents-utils.ts";
@@ -149,6 +150,32 @@ describe("resolveAgentAvatarUrl", () => {
});
});
describe("resolveChatAvatarRenderUrl", () => {
it("accepts a blob: URL produced by an authenticated avatar fetch", () => {
expect(
resolveChatAvatarRenderUrl("blob:http://localhost/uuid-123", {
identity: { avatarUrl: "/avatar/main" },
}),
).toBe("blob:http://localhost/uuid-123");
});
it("falls back to the config-sanitized avatar when no blob candidate is present", () => {
expect(
resolveChatAvatarRenderUrl(null, {
identity: { avatarUrl: "/avatar/main" },
}),
).toBe("/avatar/main");
});
it("rejects remote URLs passed as the render candidate", () => {
expect(
resolveChatAvatarRenderUrl("https://example.com/avatar.png", {
identity: { avatarUrl: "/avatar/main" },
}),
).toBe("/avatar/main");
});
});
describe("buildAgentContext", () => {
it("falls back to agent payload workspace/model when config form is unavailable", () => {
const context = buildAgentContext(

View File

@@ -224,6 +224,22 @@ export function resolveAgentAvatarUrl(
return null;
}
// Chat-render variant: accept `blob:` URLs (produced locally by
// `URL.createObjectURL` after an authenticated avatar fetch) in addition to
// config-sanitized candidates. The config path still gates untrusted
// http(s)/data sources through `resolveAgentAvatarUrl`.
export function resolveChatAvatarRenderUrl(
candidate: string | null | undefined,
agent: { identity?: { avatar?: string; avatarUrl?: string } },
agentIdentity?: AgentIdentityResult | null,
): string | null {
const trimmed = normalizeOptionalString(candidate);
if (trimmed?.startsWith("blob:")) {
return trimmed;
}
return resolveAgentAvatarUrl(agent, agentIdentity);
}
export function agentLogoUrl(basePath: string): string {
const base = normalizeOptionalString(basePath)?.replace(/\/$/, "") ?? "";
return base ? `${base}/favicon.svg` : "favicon.svg";

View File

@@ -40,7 +40,7 @@ import type { SidebarContent } from "../sidebar-content.ts";
import { detectTextDirection } from "../text-direction.ts";
import type { SessionsListResult } from "../types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts";
import { agentLogoUrl, resolveChatAvatarRenderUrl } from "./agents-utils.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
import "../components/resizable-divider.ts";
@@ -465,7 +465,7 @@ const WELCOME_SUGGESTIONS = [
function renderWelcomeState(props: ChatProps): TemplateResult {
const name = props.assistantName || "Assistant";
const avatar = resolveAgentAvatarUrl({
const avatar = resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
@@ -741,7 +741,7 @@ export function renderChat(props: ChatProps) {
const assistantIdentity = {
name: props.assistantName,
avatar:
resolveAgentAvatarUrl({
resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
@@ -866,7 +866,11 @@ export function renderChat(props: ChatProps) {
`;
}
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity, props.basePath);
return renderReadingIndicatorGroup(
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null,
);
}
if (item.kind === "stream") {
return renderStreamingGroup(
@@ -875,6 +879,7 @@ export function renderChat(props: ChatProps) {
props.onOpenSidebar,
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null,
);
}
if (item.kind === "group") {