mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
41fc4558de
commit
b18f91dc38
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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) ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user