fix(ui): icon and avatar fetches 401 first on every page load under token auth (#123199)

The Control UI put the hello device token first in its ordered HTTP
credential candidates, but the plugin/catalog/workspace icon byte routes
only accept the gateway shared secret. Every icon fetch on every page
load therefore 401'd once before retrying with the working token — and
each of those 401s is recorded by the gateway as a shared-secret
brute-force failure, escalating loopback penalty delays (250ms-5s
serialized before the response) and risking the 5-minute remote IP
lockout, where the icon loader treats the resulting 429 as terminal.

Order shared secrets first; pairing-only browsers still reach the device
token as the final candidate. Also switch the profile page hero avatar
from a single-candidate fetch (device token only, permanently 401 with
no fallback) to the same ordered candidates helper.
This commit is contained in:
Peter Steinberger
2026-08-13 20:29:15 -07:00
committed by GitHub
parent 41fc4558de
commit b18f91dc38
3 changed files with 45 additions and 11 deletions
+28
View File
@@ -0,0 +1,28 @@
// Candidate ordering is a product contract: shared secrets first, because
// several gateway byte routes (plugin/catalog/workspace icons) reject device
// tokens, and each rejected attempt pays the shared-secret brute-force
// penalty on the gateway (delay escalation, remote IP lockout).
import { describe, expect, it } from "vitest";
import { resolveControlUiAuthCandidates } from "./control-ui-auth.ts";
describe("resolveControlUiAuthCandidates", () => {
it("orders shared secrets before the hello device token", () => {
expect(
resolveControlUiAuthCandidates({
hello: { auth: { deviceToken: "device-token" } } as never,
settings: { token: "shared-token" },
password: "shared-password",
}),
).toEqual(["shared-token", "shared-password", "device-token"]);
});
it("keeps the device token for pairing-only browsers", () => {
expect(
resolveControlUiAuthCandidates({
hello: { auth: { deviceToken: "device-token" } } as never,
settings: { token: "" },
password: "",
}),
).toEqual(["device-token"]);
});
});
+6 -1
View File
@@ -40,12 +40,17 @@ export function resolveControlUiAuthHeader(source: ControlUiAuthSource): string
// call sites that can retry a single request against an alternate credential
// when the first returns 401 — for example, recovering from a stale
// `settings.token` when the live session is authenticated via `password`.
// Shared secrets go first: several byte routes (plugin/catalog/workspace
// icons) only accept the gateway shared secret, so leading with the hello
// device token made every icon fetch 401 first — and each of those 401s pays
// the shared-secret brute-force penalty on the gateway. Pairing-only browsers
// still reach the device token as the last candidate.
export function resolveControlUiAuthCandidates(source: ControlUiAuthSource): string[] {
return uniqueStrings(
[
normalizeOptionalString(source.hello?.auth?.deviceToken),
normalizeOptionalString(source.settings?.token),
normalizeOptionalString(source.password),
normalizeOptionalString(source.hello?.auth?.deviceToken),
].flatMap((raw) => sanitizeHeaderToken(raw ?? null) ?? []),
);
}
+11 -10
View File
@@ -14,7 +14,7 @@ import {
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { resolveControlUiAuthToken } from "../../app/control-ui-auth.ts";
import { resolveControlUiAuthCandidates } from "../../app/control-ui-auth.ts";
import type { AuthenticatedUser } from "../../app/user-profile.ts";
import { resolveCurrentSelfUser, userProfileAvatarUrl } from "../../app/user-profile.ts";
import { icons } from "../../components/icons.ts";
@@ -61,7 +61,7 @@ export class ProfilePage extends OpenClawLightDomElement {
private client: GatewayBrowserClient | null = null;
private connected = false;
private heroAvatarAuthToken: string | null = null;
private heroAvatarAuthCandidates: string[] = [];
private heroAvatarAuthReady = false;
private readonly heroAvatarLoader = new AuthenticatedAvatarRouteLoader(() => {
if (this.isConnected) {
@@ -88,7 +88,7 @@ export class ProfilePage extends OpenClawLightDomElement {
this.subscriptions = [];
this.identityRequestId += 1;
this.heroAvatarLoader.reset();
this.heroAvatarAuthToken = null;
this.heroAvatarAuthCandidates = [];
this.heroAvatarAuthReady = false;
this.client = null;
this.connected = false;
@@ -96,13 +96,17 @@ export class ProfilePage extends OpenClawLightDomElement {
}
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) {
const nextHeroAvatarAuthToken = resolveControlUiAuthToken({
// The /api/users avatar route only accepts shared secrets; a single
// device-token candidate would 401 forever. Offer the full ordered list.
const nextHeroAvatarAuthCandidates = resolveControlUiAuthCandidates({
hello: snapshot.hello,
settings: { token: this.context.gateway.connection.token },
password: this.context.gateway.connection.password,
});
if (nextHeroAvatarAuthToken !== this.heroAvatarAuthToken) {
this.heroAvatarAuthToken = nextHeroAvatarAuthToken;
if (
nextHeroAvatarAuthCandidates.join("\u0000") !== this.heroAvatarAuthCandidates.join("\u0000")
) {
this.heroAvatarAuthCandidates = nextHeroAvatarAuthCandidates;
}
this.heroAvatarAuthReady = Boolean(
snapshot.hello ||
@@ -359,10 +363,7 @@ export class ProfilePage extends OpenClawLightDomElement {
private renderAvatar(avatarUrl: string | null, textAvatar: string | null, name: string) {
const imageUrl = avatarUrl?.startsWith("/")
? this.heroAvatarAuthReady
? this.heroAvatarLoader.resolve(
avatarUrl,
this.heroAvatarAuthToken ? [this.heroAvatarAuthToken] : [],
)
? this.heroAvatarLoader.resolve(avatarUrl, this.heroAvatarAuthCandidates)
: null
: avatarUrl;
if (avatarUrl && avatarUrl !== this.failedHeroAvatarUrl) {