Skip to content

Commit 2ce16e5

Browse files
authored
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
1 parent 6b185e2 commit 2ce16e5

15 files changed

Lines changed: 513 additions & 87 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323
- 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)
2424
- CLI/channels: keep `status`, `health`, `channels list`, and `channels status` on read-only channel metadata when Telegram, Slack, Discord, or third-party channel plugins are configured, avoiding full bundled plugin runtime imports on those cold paths. Fixes #69042. (#69479) Thanks @gumadeiras.
2525
- Synology Chat: validate outbound webhook `file_url` values against the shared SSRF policy before forwarding to the NAS, rejecting malformed URLs, non-`http(s)` schemes, and private/blocked network targets so the NAS cannot be used as a confused deputy to fetch internal addresses. (#69784) Thanks @eleqtrizit.
26+
- Gateway/Control UI: require gateway auth on the Control UI avatar route (`GET /avatar/<agentId>` and `?meta=1` metadata) when auth is configured, matching the sibling assistant-media route, and propagate the existing gateway token through the UI avatar fetch (bearer header + authenticated blob URL) so authenticated dashboards still load local avatars. (#69775)
2627

2728
## 2026.4.20
2829

src/gateway/control-ui.http.test.ts

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,29 @@ describe("handleControlUiHttpRequest", () => {
6767
return { res, end, handled };
6868
}
6969

70-
function runAvatarRequest(params: {
70+
async function runAvatarRequest(params: {
7171
url: string;
7272
method: "GET" | "HEAD";
7373
resolveAvatar: Parameters<typeof handleControlUiAvatarRequest>[2]["resolveAvatar"];
7474
basePath?: string;
75+
auth?: ResolvedGatewayAuth;
76+
headers?: IncomingMessage["headers"];
77+
trustedProxies?: string[];
78+
remoteAddress?: string;
7579
}) {
7680
const { res, end } = makeMockHttpResponse();
77-
const handled = handleControlUiAvatarRequest(
78-
{ url: params.url, method: params.method } as IncomingMessage,
81+
const handled = await handleControlUiAvatarRequest(
82+
{
83+
url: params.url,
84+
method: params.method,
85+
headers: params.headers ?? {},
86+
socket: { remoteAddress: params.remoteAddress ?? "127.0.0.1" },
87+
} as IncomingMessage,
7988
res,
8089
{
8190
...(params.basePath ? { basePath: params.basePath } : {}),
91+
...(params.auth ? { auth: params.auth } : {}),
92+
...(params.trustedProxies ? { trustedProxies: params.trustedProxies } : {}),
8293
resolveAvatar: params.resolveAvatar,
8394
},
8495
);
@@ -148,6 +159,24 @@ describe("handleControlUiHttpRequest", () => {
148159
});
149160
}
150161

162+
async function runTrustedProxyAvatarRequest(params: {
163+
agentId?: string;
164+
meta?: boolean;
165+
headers?: IncomingMessage["headers"];
166+
resolveAvatar?: Parameters<typeof handleControlUiAvatarRequest>[2]["resolveAvatar"];
167+
}) {
168+
return await runAvatarRequest({
169+
url: `/avatar/${params.agentId ?? "main"}${params.meta ? "?meta=1" : ""}`,
170+
method: "GET",
171+
auth: createTrustedProxyAuth(),
172+
trustedProxies: ["10.0.0.1"],
173+
remoteAddress: "10.0.0.1",
174+
headers: createTrustedProxyHeaders(params.headers),
175+
resolveAvatar:
176+
params.resolveAvatar ?? (() => ({ kind: "remote", url: "https://example.com/avatar.png" })),
177+
});
178+
}
179+
151180
function expectMissingOperatorReadResponse(params: {
152181
handled: boolean;
153182
res: ReturnType<typeof makeMockHttpResponse>["res"];
@@ -471,7 +500,7 @@ describe("handleControlUiHttpRequest", () => {
471500
const avatarPath = path.join(tmp, "main.png");
472501
await fs.writeFile(avatarPath, "avatar-bytes\n");
473502

474-
const { res, end, handled } = runAvatarRequest({
503+
const { res, end, handled } = await runAvatarRequest({
475504
url: "/avatar/main",
476505
method: "GET",
477506
resolveAvatar: () => ({ kind: "local", filePath: avatarPath }),
@@ -494,7 +523,7 @@ describe("handleControlUiHttpRequest", () => {
494523
const linkPath = path.join(tmp, "avatar-link.png");
495524
await fs.symlink(outsideFile, linkPath);
496525

497-
const { res, end, handled } = runAvatarRequest({
526+
const { res, end, handled } = await runAvatarRequest({
498527
url: "/avatar/main",
499528
method: "GET",
500529
resolveAvatar: () => ({ kind: "local", filePath: linkPath }),
@@ -507,6 +536,71 @@ describe("handleControlUiHttpRequest", () => {
507536
}
508537
});
509538

539+
it("serves local avatar bytes when auth is enabled and the token is valid", async () => {
540+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-auth-"));
541+
try {
542+
const avatarPath = path.join(tmp, "main.png");
543+
await fs.writeFile(avatarPath, "avatar-bytes\n");
544+
545+
const { res, handled } = await runAvatarRequest({
546+
url: "/avatar/main",
547+
method: "GET",
548+
auth: { mode: "token", token: "test-token", allowTailscale: false },
549+
headers: {
550+
authorization: "Bearer test-token",
551+
},
552+
resolveAvatar: () => ({ kind: "local", filePath: avatarPath }),
553+
});
554+
555+
expect(handled).toBe(true);
556+
expect(res.statusCode).toBe(200);
557+
} finally {
558+
await fs.rm(tmp, { recursive: true, force: true });
559+
}
560+
});
561+
562+
it("returns avatar metadata when auth is enabled and the token is valid", async () => {
563+
const { res, end, handled } = await runAvatarRequest({
564+
url: "/avatar/main?meta=1",
565+
method: "GET",
566+
auth: { mode: "token", token: "test-token", allowTailscale: false },
567+
headers: {
568+
authorization: "Bearer test-token",
569+
},
570+
resolveAvatar: () => ({ kind: "remote", url: "https://example.com/avatar.png" }),
571+
});
572+
573+
expect(handled).toBe(true);
574+
expect(res.statusCode).toBe(200);
575+
expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({
576+
avatarUrl: "https://example.com/avatar.png",
577+
});
578+
});
579+
580+
it("rejects avatar requests without a valid auth token when auth is enabled", async () => {
581+
const { res, handled, end } = await runAvatarRequest({
582+
url: "/avatar/main",
583+
method: "GET",
584+
auth: { mode: "token", token: "test-token", allowTailscale: false },
585+
resolveAvatar: () => ({ kind: "remote", url: "https://example.com/avatar.png" }),
586+
});
587+
588+
expect(handled).toBe(true);
589+
expect(res.statusCode).toBe(401);
590+
expect(String(end.mock.calls[0]?.[0] ?? "")).toContain("Unauthorized");
591+
});
592+
593+
it("rejects trusted-proxy avatar metadata requests without operator.read scope", async () => {
594+
const { res, handled, end } = await runTrustedProxyAvatarRequest({
595+
meta: true,
596+
headers: {
597+
"x-openclaw-scopes": "",
598+
},
599+
});
600+
601+
expectMissingOperatorReadResponse({ handled, res, end });
602+
});
603+
510604
it("rejects symlinked assets that resolve outside control-ui root", async () => {
511605
await withControlUiRoot({
512606
fn: async (tmp) => {

src/gateway/control-ui.ts

Lines changed: 102 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,78 @@ function resolveAssistantMediaAuthToken(req: IncomingMessage): string | undefine
211211
}
212212
}
213213

214+
function resolveControlUiReadAuthToken(
215+
req: IncomingMessage,
216+
opts?: { allowQueryToken?: boolean },
217+
): string | undefined {
218+
const bearer = getBearerToken(req);
219+
if (bearer) {
220+
return bearer;
221+
}
222+
if (!opts?.allowQueryToken) {
223+
return undefined;
224+
}
225+
return resolveAssistantMediaAuthToken(req);
226+
}
227+
228+
async function authorizeControlUiReadRequest(
229+
req: IncomingMessage,
230+
res: ServerResponse,
231+
opts?: {
232+
auth?: ResolvedGatewayAuth;
233+
trustedProxies?: string[];
234+
allowRealIpFallback?: boolean;
235+
rateLimiter?: AuthRateLimiter;
236+
allowQueryToken?: boolean;
237+
},
238+
): Promise<boolean> {
239+
if (!opts?.auth) {
240+
return true;
241+
}
242+
243+
const token = resolveControlUiReadAuthToken(req, {
244+
allowQueryToken: opts.allowQueryToken,
245+
});
246+
const authResult = await authorizeHttpGatewayConnect({
247+
auth: opts.auth,
248+
connectAuth: token ? { token, password: token } : null,
249+
req,
250+
browserOriginPolicy: resolveHttpBrowserOriginPolicy(req),
251+
trustedProxies: opts.trustedProxies,
252+
allowRealIpFallback: opts.allowRealIpFallback,
253+
rateLimiter: opts.rateLimiter,
254+
});
255+
if (!authResult.ok) {
256+
sendGatewayAuthFailure(res, authResult);
257+
return false;
258+
}
259+
260+
const trustDeclaredOperatorScopes =
261+
authResult.method !== "token" &&
262+
authResult.method !== "password" &&
263+
authResult.method !== "none";
264+
if (!trustDeclaredOperatorScopes) {
265+
return true;
266+
}
267+
268+
const requestedScopes = resolveTrustedHttpOperatorScopes(req, {
269+
trustDeclaredOperatorScopes,
270+
});
271+
const scopeAuth = authorizeOperatorScopesForMethod("assistant.media.get", requestedScopes);
272+
if (!scopeAuth.allowed) {
273+
sendJson(res, 403, {
274+
ok: false,
275+
error: {
276+
type: "forbidden",
277+
message: `missing scope: ${scopeAuth.missingScope}`,
278+
},
279+
});
280+
return false;
281+
}
282+
283+
return true;
284+
}
285+
214286
type AssistantMediaAvailability =
215287
| { available: true }
216288
| { available: false; reason: string; code: string };
@@ -297,41 +369,16 @@ export async function handleControlUiAssistantMediaRequest(
297369
}
298370

299371
applyControlUiSecurityHeaders(res);
300-
if (opts?.auth) {
301-
const token = resolveAssistantMediaAuthToken(req);
302-
const authResult = await authorizeHttpGatewayConnect({
303-
auth: opts.auth,
304-
connectAuth: token ? { token, password: token } : null,
305-
req,
306-
browserOriginPolicy: resolveHttpBrowserOriginPolicy(req),
307-
trustedProxies: opts.trustedProxies,
308-
allowRealIpFallback: opts.allowRealIpFallback,
309-
rateLimiter: opts.rateLimiter,
310-
});
311-
if (!authResult.ok) {
312-
sendGatewayAuthFailure(res, authResult);
313-
return true;
314-
}
315-
const trustDeclaredOperatorScopes =
316-
authResult.method !== "token" &&
317-
authResult.method !== "password" &&
318-
authResult.method !== "none";
319-
if (trustDeclaredOperatorScopes) {
320-
const requestedScopes = resolveTrustedHttpOperatorScopes(req, {
321-
trustDeclaredOperatorScopes,
322-
});
323-
const scopeAuth = authorizeOperatorScopesForMethod("assistant.media.get", requestedScopes);
324-
if (!scopeAuth.allowed) {
325-
sendJson(res, 403, {
326-
ok: false,
327-
error: {
328-
type: "forbidden",
329-
message: `missing scope: ${scopeAuth.missingScope}`,
330-
},
331-
});
332-
return true;
333-
}
334-
}
372+
if (
373+
!(await authorizeControlUiReadRequest(req, res, {
374+
auth: opts?.auth,
375+
trustedProxies: opts?.trustedProxies,
376+
allowRealIpFallback: opts?.allowRealIpFallback,
377+
rateLimiter: opts?.rateLimiter,
378+
allowQueryToken: true,
379+
}))
380+
) {
381+
return true;
335382
}
336383
const source = normalizeAssistantMediaSource(url.searchParams.get("source") ?? "");
337384
if (!source) {
@@ -401,11 +448,18 @@ export async function handleControlUiAssistantMediaRequest(
401448
}
402449
}
403450

404-
export function handleControlUiAvatarRequest(
451+
export async function handleControlUiAvatarRequest(
405452
req: IncomingMessage,
406453
res: ServerResponse,
407-
opts: { basePath?: string; resolveAvatar: (agentId: string) => ControlUiAvatarResolution },
408-
): boolean {
454+
opts: {
455+
basePath?: string;
456+
resolveAvatar: (agentId: string) => ControlUiAvatarResolution;
457+
auth?: ResolvedGatewayAuth;
458+
trustedProxies?: string[];
459+
allowRealIpFallback?: boolean;
460+
rateLimiter?: AuthRateLimiter;
461+
},
462+
): Promise<boolean> {
409463
const urlRaw = req.url;
410464
if (!urlRaw) {
411465
return false;
@@ -425,6 +479,16 @@ export function handleControlUiAvatarRequest(
425479
}
426480

427481
applyControlUiSecurityHeaders(res);
482+
if (
483+
!(await authorizeControlUiReadRequest(req, res, {
484+
auth: opts.auth,
485+
trustedProxies: opts.trustedProxies,
486+
allowRealIpFallback: opts.allowRealIpFallback,
487+
rateLimiter: opts.rateLimiter,
488+
}))
489+
) {
490+
return true;
491+
}
428492

429493
const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean);
430494
const agentId = agentIdParts[0] ?? "";

src/gateway/server-http.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,10 @@ export function createGatewayHttpServer(opts: {
10811081
const { resolveAgentAvatar } = await getIdentityAvatarModule();
10821082
return handleControlUiAvatarRequest(req, res, {
10831083
basePath: controlUiBasePath,
1084+
auth: resolvedAuth,
1085+
trustedProxies,
1086+
allowRealIpFallback,
1087+
rateLimiter,
10841088
resolveAvatar: (agentId) =>
10851089
resolveAgentAvatar(configSnapshot, agentId, { includeUiOverride: true }),
10861090
});

ui/src/ui/app-channels.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
waitWhatsAppLogin,
66
type ChannelsState,
77
} from "./controllers/channels.ts";
8+
import { resolveControlUiAuthHeader } from "./control-ui-auth.ts";
89
import { loadConfig, saveConfig, type ConfigState } from "./controllers/config.ts";
9-
import { normalizeOptionalString } from "./string-coerce.ts";
1010
import type { NostrProfile } from "./types.ts";
1111
import { createNostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
1212

@@ -78,24 +78,8 @@ function buildNostrProfileUrl(accountId: string, suffix = ""): string {
7878
return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`;
7979
}
8080

81-
function resolveGatewayHttpAuthHeader(host: ChannelsActionHost): string | null {
82-
const deviceToken = normalizeOptionalString(host.hello?.auth?.deviceToken);
83-
if (deviceToken) {
84-
return `Bearer ${deviceToken}`;
85-
}
86-
const token = normalizeOptionalString(host.settings.token);
87-
if (token) {
88-
return `Bearer ${token}`;
89-
}
90-
const password = normalizeOptionalString(host.password);
91-
if (password) {
92-
return `Bearer ${password}`;
93-
}
94-
return null;
95-
}
96-
9781
function buildGatewayHttpHeaders(host: ChannelsActionHost): Record<string, string> {
98-
const authorization = resolveGatewayHttpAuthHeader(host);
82+
const authorization = resolveControlUiAuthHeader(host);
9983
return authorization ? { Authorization: authorization } : {};
10084
}
10185

0 commit comments

Comments
 (0)