Medium severity5.3NVD Advisory· Published May 11, 2026· Updated May 13, 2026
CVE-2026-44994
CVE-2026-44994
Description
OpenClaw before 2026.4.22 contains an authentication bypass vulnerability in the Control UI bootstrap config endpoint that allows unauthenticated attackers to read sensitive configuration fields. Attackers can access the bootstrap config route without a valid Gateway token to expose sensitive bootstrap and config information intended only for authenticated Control UI sessions.
Affected products
1Patches
12321d67263bcfix(gateway): require auth for control ui bootstrap config (#70247)
11 files changed · +339 −42
CHANGELOG.md+1 −0 modified@@ -114,6 +114,7 @@ Docs: https://docs.openclaw.ai - Agents/Pi: keep the filtered tool-name allowlist active for embedded OpenAI/OpenAI Codex GPT-5 runs and compaction sessions, so bundled and client tools still execute after the Pi `0.68.1` session-tool allowlist change instead of stopping at plan-only replies with no tool call. (#70281) Thanks @jalehman. - Agents/Pi: honor explicit `strict-agentic` execution contracts for incomplete-turn retry guards across providers, so manually opted-in local or compatible models get the same retry behavior without relying on OpenAI model inference. (#66750) Thanks @ziomancer. - OpenShell/sandbox: pin verified file reads to an already-opened descriptor, walk the ancestor chain for symlinked parents on platforms without fd-path readlink, and re-check file identity so parent symlink swaps cannot redirect in-sandbox reads to host files outside the allowed mount root. (#69798) Thanks @drobison00. +- Gateway/Control UI: require authenticated Control UI read access before serving `/__openclaw/control-ui-config.json` when `gateway.auth` is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00. ## 2026.4.21
src/gateway/control-ui.auto-root.http.test.ts+3 −3 modified@@ -49,7 +49,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => { resolveControlUiRootSyncMock.mockReturnValue(tmp); const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/assets/app.hl.js", method: "GET" } as IncomingMessage, res, ); @@ -70,7 +70,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => { resolveControlUiRootSyncMock.mockReturnValue(tmp); const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/dashboard", method: "GET" } as IncomingMessage, res, ); @@ -91,7 +91,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => { resolveControlUiRootSyncMock.mockReturnValue(tmp); const { res } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/assets/app.hl.js", method: "GET" } as IncomingMessage, res, );
src/gateway/control-ui.http.test.ts+81 −22 modified@@ -48,15 +48,15 @@ describe("handleControlUiHttpRequest", () => { expect(params.end).toHaveBeenCalledWith("Not Found"); } - function runControlUiRequest(params: { + async function runControlUiRequest(params: { url: string; method: "GET" | "HEAD" | "POST"; rootPath: string; basePath?: string; rootKind?: "resolved" | "bundled"; }) { const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: params.url, method: params.method } as IncomingMessage, res, { @@ -67,6 +67,33 @@ describe("handleControlUiHttpRequest", () => { return { res, end, handled }; } + async function runBootstrapConfigRequest(params: { + rootPath: string; + basePath?: string; + auth?: ResolvedGatewayAuth; + headers?: IncomingMessage["headers"]; + }) { + const { res, end } = makeMockHttpResponse(); + const url = params.basePath + ? `${params.basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}` + : CONTROL_UI_BOOTSTRAP_CONFIG_PATH; + const handled = await handleControlUiHttpRequest( + { + url, + method: "GET", + headers: params.headers ?? {}, + socket: { remoteAddress: "127.0.0.1" }, + } as IncomingMessage, + res, + { + ...(params.basePath ? { basePath: params.basePath } : {}), + ...(params.auth ? { auth: params.auth } : {}), + root: { kind: "resolved", path: params.rootPath }, + }, + ); + return { res, end, handled }; + } + async function runAvatarRequest(params: { url: string; method: "GET" | "HEAD"; @@ -241,7 +268,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { const { res, setHeader } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/", method: "GET" } as IncomingMessage, res, { @@ -405,7 +432,7 @@ describe("handleControlUiHttpRequest", () => { indexHtml: html, fn: async (tmp) => { const { res, setHeader } = makeMockHttpResponse(); - handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { + await handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp }, }); const cspCalls = setHeader.mock.calls.filter( @@ -424,7 +451,7 @@ describe("handleControlUiHttpRequest", () => { indexHtml: html, fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/", method: "GET" } as IncomingMessage, res, { @@ -445,7 +472,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage, res, { @@ -467,11 +494,43 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("rejects bootstrap config requests without a valid auth token when auth is enabled", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, handled, end } = await runBootstrapConfigRequest({ + rootPath: tmp, + auth: { mode: "token", token: "test-token", allowTailscale: false }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + expect(String(end.mock.calls[0]?.[0] ?? "")).toContain("Unauthorized"); + }, + }); + }); + + it("serves bootstrap config JSON when auth is enabled and the token is valid", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, handled, end } = await runBootstrapConfigRequest({ + rootPath: tmp, + auth: { mode: "token", token: "test-token", allowTailscale: false }, + headers: { + authorization: "Bearer test-token", + }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + const parsed = parseBootstrapPayload(end); + expect(parsed.assistantAgentId).toBe("main"); + }, + }); + }); + it("serves bootstrap config JSON under basePath", async () => { await withControlUiRoot({ fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, method: "GET" } as IncomingMessage, res, { @@ -613,7 +672,7 @@ describe("handleControlUiHttpRequest", () => { await fs.symlink(outsideFile, path.join(assetsDir, "leak.txt")); const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/assets/leak.txt", method: "GET" } as IncomingMessage, res, { @@ -634,7 +693,7 @@ describe("handleControlUiHttpRequest", () => { const { assetsDir, filePath } = await writeAssetFile(tmp, "actual.txt", "inside-ok\n"); await fs.symlink(filePath, path.join(assetsDir, "linked.txt")); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/assets/linked.txt", method: "GET", rootPath: tmp, @@ -652,7 +711,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { await writeAssetFile(tmp, "actual.txt", "inside-ok\n"); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/assets/actual.txt", method: "HEAD", rootPath: tmp, @@ -675,7 +734,7 @@ describe("handleControlUiHttpRequest", () => { await fs.rm(path.join(tmp, "index.html")); await fs.symlink(outsideIndex, path.join(tmp, "index.html")); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/app/route", method: "GET", rootPath: tmp, @@ -698,7 +757,7 @@ describe("handleControlUiHttpRequest", () => { await fs.rm(path.join(tmp, "index.html")); await fs.link(outsideIndex, path.join(tmp, "index.html")); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/", method: "GET", rootPath: tmp, @@ -716,7 +775,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { await createHardlinkedAssetFile(tmp); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/assets/app.hl.js", method: "GET", rootPath: tmp, @@ -734,7 +793,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { await createHardlinkedAssetFile(tmp); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/assets/app.hl.js", method: "GET", rootPath: tmp, @@ -753,7 +812,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { for (const webhookPath of ["/bluebubbles-webhook", "/custom-webhook", "/callback"]) { const { res } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: webhookPath, method: "POST" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -770,7 +829,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { const { res } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/bluebubbles-webhook", method: "POST" } as IncomingMessage, res, { basePath: "/openclaw", root: { kind: "resolved", path: tmp } }, @@ -784,7 +843,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { for (const apiPath of ["/api", "/api/sessions", "/api/channels/nostr"]) { - const { handled } = runControlUiRequest({ + const { handled } = await runControlUiRequest({ url: apiPath, method: "GET", rootPath: tmp, @@ -799,7 +858,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { for (const pluginPath of ["/plugins", "/plugins/diffs/view/abc/def"]) { - const { handled } = runControlUiRequest({ + const { handled } = await runControlUiRequest({ url: pluginPath, method: "GET", rootPath: tmp, @@ -813,7 +872,7 @@ describe("handleControlUiHttpRequest", () => { it("falls through POST requests when basePath is empty", async () => { await withControlUiRoot({ fn: async (tmp) => { - const { handled, end } = runControlUiRequest({ + const { handled, end } = await runControlUiRequest({ url: "/webhook/bluebubbles", method: "POST", rootPath: tmp, @@ -828,7 +887,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { for (const route of ["/openclaw", "/openclaw/", "/openclaw/some-page"]) { - const { handled, end } = runControlUiRequest({ + const { handled, end } = await runControlUiRequest({ url: route, method: "POST", rootPath: tmp, @@ -850,7 +909,7 @@ describe("handleControlUiHttpRequest", () => { const secretPathUrl = secretPath.split(path.sep).join("/"); const absolutePathUrl = secretPathUrl.startsWith("/") ? secretPathUrl : `/${secretPathUrl}`; - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: `/openclaw/${absolutePathUrl}`, method: "GET", rootPath: root, @@ -879,7 +938,7 @@ describe("handleControlUiHttpRequest", () => { throw error; } - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/openclaw/assets/leak.txt", method: "GET", rootPath: root,
src/gateway/control-ui.ts+16 −2 modified@@ -55,6 +55,10 @@ export type ControlUiRequestOptions = { config?: OpenClawConfig; agentId?: string; root?: ControlUiRootState; + auth?: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; }; export type ControlUiRootState = @@ -617,11 +621,11 @@ function isSafeRelativePath(relPath: string) { return true; } -export function handleControlUiHttpRequest( +export async function handleControlUiHttpRequest( req: IncomingMessage, res: ServerResponse, opts?: ControlUiRequestOptions, -): boolean { +): Promise<boolean> { const urlRaw = req.url; if (!urlRaw) { return false; @@ -657,6 +661,16 @@ export function handleControlUiHttpRequest( ? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}` : CONTROL_UI_BOOTSTRAP_CONFIG_PATH; if (pathname === bootstrapConfigPath) { + if ( + !(await authorizeControlUiReadRequest(req, res, { + auth: opts?.auth, + trustedProxies: opts?.trustedProxies, + allowRealIpFallback: opts?.allowRealIpFallback, + rateLimiter: opts?.rateLimiter, + })) + ) { + return true; + } const config = opts?.config; const identity = config ? resolveAssistantIdentity({ cfg: config, agentId: opts?.agentId })
src/gateway/gateway-misc.test.ts+6 −6 modified@@ -83,7 +83,7 @@ describe("GatewayClient", () => { it("returns 404 for missing static asset paths instead of SPA fallback", async () => { await withControlUiRoot({ faviconSvg: "<svg/>" }, async (tmp) => { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/webchat/favicon.svg", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -96,7 +96,7 @@ describe("GatewayClient", () => { it("returns 404 for missing static assets with query strings", async () => { await withControlUiRoot({}, async (tmp) => { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/webchat/favicon.svg?v=1", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -109,7 +109,7 @@ describe("GatewayClient", () => { it("still serves SPA fallback for extensionless paths", async () => { await withControlUiRoot({}, async (tmp) => { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/webchat/chat", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -122,7 +122,7 @@ describe("GatewayClient", () => { it("HEAD returns 404 for missing static assets consistent with GET", async () => { await withControlUiRoot({}, async (tmp) => { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/webchat/favicon.svg", method: "HEAD" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -136,7 +136,7 @@ describe("GatewayClient", () => { await withControlUiRoot({}, async (tmp) => { for (const route of ["/webchat/user/jane.doe", "/webchat/v2.0", "/settings/v1.2"]) { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: route, method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -150,7 +150,7 @@ describe("GatewayClient", () => { it("serves SPA fallback for .html paths that do not exist on disk", async () => { await withControlUiRoot({}, async (tmp) => { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/webchat/foo.html", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } },
src/gateway/server-http.ts+4 −0 modified@@ -1099,6 +1099,10 @@ export function createGatewayHttpServer(opts: { config: configSnapshot, agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId, root: controlUiRoot, + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, }), }); }
ui/src/ui/app-gateway.node.test.ts+19 −0 modified@@ -5,6 +5,7 @@ import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts" import type { GatewayHelloOk } from "./gateway.ts"; const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined)); +const loadControlUiBootstrapConfigMock = vi.hoisted(() => vi.fn(async () => undefined)); type GatewayClientMock = { start: ReturnType<typeof vi.fn>; @@ -94,6 +95,10 @@ vi.mock("./controllers/chat.ts", async (importOriginal) => { }; }); +vi.mock("./controllers/control-ui-bootstrap.ts", () => ({ + loadControlUiBootstrapConfig: loadControlUiBootstrapConfigMock, +})); + type TestGatewayHost = Parameters<typeof connectGateway>[0] & { chatSideResult: unknown; chatSideResultTerminalRuns: Set<string>; @@ -192,6 +197,7 @@ describe("connectGateway", () => { beforeEach(() => { gatewayClientInstances.length = 0; loadChatHistoryMock.mockClear(); + loadControlUiBootstrapConfigMock.mockClear(); }); it("ignores stale client onGap callbacks after reconnect", () => { @@ -505,6 +511,19 @@ describe("connectGateway", () => { expect(host.lastError).toBe("disconnected (1006): no reason"); }); + it("refreshes bootstrap config after hello", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitHello(); + + expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledTimes(1); + expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledWith(host); + }); + it("keeps shutdown restart reasons on service restart closes", () => { const host = createHost();
ui/src/ui/app-gateway.ts+4 −0 modified@@ -30,6 +30,7 @@ import { type ChatEventPayload, type ChatState, } from "./controllers/chat.ts"; +import { loadControlUiBootstrapConfig } from "./controllers/control-ui-bootstrap.ts"; import { loadDevices, type DevicesState } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import { @@ -292,6 +293,9 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption host.lastErrorCode = null; host.hello = hello; applySnapshot(host, hello); + void loadControlUiBootstrapConfig( + host as unknown as Parameters<typeof loadControlUiBootstrapConfig>[0], + ); // Reset orphaned chat run state from before disconnect. // Any in-flight run's final event was lost during the disconnect window. host.chatRunId = null;
ui/src/ui/controllers/control-ui-bootstrap.test.ts+139 −0 modified@@ -101,4 +101,143 @@ describe("loadControlUiBootstrapConfig", () => { vi.unstubAllGlobals(); }); + + it("includes the configured auth token on bootstrap fetches", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "/openclaw", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + settings: { token: "session-token" }, + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledWith( + `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Accept: "application/json", + Authorization: "Bearer session-token", + }), + }), + ); + + vi.unstubAllGlobals(); + }); + + it("retries with the alternate shared-secret credential when the first returns 401", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 401 }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + basePath: "", + assistantName: "Ops", + assistantAvatar: null, + assistantAgentId: null, + serverVersion: "2026.4.22", + localMediaPreviewRoots: [], + embedSandbox: "scripts", + allowExternalEmbedUrls: false, + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + settings: { token: "stale-token" }, + password: "fresh-password", + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, firstInit] = fetchMock.mock.calls[0] ?? []; + const [, secondInit] = fetchMock.mock.calls[1] ?? []; + expect((firstInit?.headers as Record<string, string> | undefined)?.Authorization).toBe( + "Bearer stale-token", + ); + expect((secondInit?.headers as Record<string, string> | undefined)?.Authorization).toBe( + "Bearer fresh-password", + ); + expect(state.assistantName).toBe("Ops"); + expect(state.serverVersion).toBe("2026.4.22"); + + vi.unstubAllGlobals(); + }); + + it("stops retrying on non-auth errors", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce({ ok: false, status: 500 }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + settings: { token: "a" }, + password: "b", + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(state.assistantName).toBe("Assistant"); + + vi.unstubAllGlobals(); + }); + + it("does not attach auth headers to protocol-relative bootstrap URLs", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "//evil.example", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + settings: { token: "session-token" }, + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledWith( + `//evil.example${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Accept: "application/json", + }), + }), + ); + const [, init] = fetchMock.mock.calls[0] ?? []; + expect((init?.headers as Record<string, string> | undefined)?.Authorization).toBeUndefined(); + + vi.unstubAllGlobals(); + }); });
ui/src/ui/controllers/control-ui-bootstrap.ts+28 −6 modified@@ -4,6 +4,7 @@ import { type ControlUiEmbedSandboxMode, } from "../../../../src/gateway/control-ui-contract.js"; import { normalizeAssistantIdentity } from "../assistant-identity.ts"; +import { resolveControlUiAuthCandidates } from "../control-ui-auth.ts"; import { normalizeBasePath } from "../navigation.ts"; export type ControlUiBootstrapState = { @@ -15,6 +16,9 @@ export type ControlUiBootstrapState = { localMediaPreviewRoots: string[]; embedSandboxMode: ControlUiEmbedSandboxMode; allowExternalEmbedUrls: boolean; + hello?: { auth?: { deviceToken?: string | null } | null } | null; + settings?: { token?: string | null } | null; + password?: string | null; }; export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapState) { @@ -31,12 +35,30 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat : CONTROL_UI_BOOTSTRAP_CONFIG_PATH; try { - const res = await fetch(url, { - method: "GET", - headers: { Accept: "application/json" }, - credentials: "same-origin", - }); - if (!res.ok) { + const resolvedUrl = new URL(url, window.location.origin); + const sameOrigin = resolvedUrl.origin === window.location.origin; + const authCandidates = sameOrigin ? resolveControlUiAuthCandidates(state) : []; + // If credentials are available, try them in priority order; on 401/403 + // retry with the next candidate — recovers from a stale `settings.token` + // when the live session is authenticated via `password` (or vice versa). + // If no credentials are available, fall through with no Authorization + // header so bootstrap still works on auth-disabled deployments. + const attempts: string[] = authCandidates.length > 0 ? authCandidates : [""]; + let res: Response | null = null; + for (const candidate of attempts) { + const headers: Record<string, string> = { Accept: "application/json" }; + if (candidate) { + headers.Authorization = `Bearer ${candidate}`; + } + res = await fetch(url, { method: "GET", headers, credentials: "same-origin" }); + if (res.ok) { + break; + } + if (res.status !== 401 && res.status !== 403) { + return; + } + } + if (!res || !res.ok) { return; } const parsed = (await res.json()) as ControlUiBootstrapConfig;
ui/src/ui/control-ui-auth.ts+38 −3 modified@@ -6,11 +6,25 @@ type ControlUiAuthSource = { password?: string | null; }; +// The gateway's shared-secret auth contract accepts either `token` or +// `password` as the Bearer credential on authenticated control-UI routes. +// Passing the password through the Authorization header is the intended +// server-side contract for `gateway.auth.mode="password"`. Callers that need +// resilience to stale credentials should use `resolveControlUiAuthCandidates` +// below to retry with the alternate credential on 401. +function sanitizeHeaderToken(value: string | null): string | null { + if (!value) { + return null; + } + // Reject tokens that would smuggle CR/LF into the HTTP header. + return /[\r\n]/.test(value) ? null : value; +} + export function resolveControlUiAuthToken(source: ControlUiAuthSource): string | null { return ( - normalizeOptionalString(source.hello?.auth?.deviceToken) ?? - normalizeOptionalString(source.settings?.token) ?? - normalizeOptionalString(source.password) ?? + sanitizeHeaderToken(normalizeOptionalString(source.hello?.auth?.deviceToken) ?? null) ?? + sanitizeHeaderToken(normalizeOptionalString(source.settings?.token) ?? null) ?? + sanitizeHeaderToken(normalizeOptionalString(source.password) ?? null) ?? null ); } @@ -19,3 +33,24 @@ export function resolveControlUiAuthHeader(source: ControlUiAuthSource): string const token = resolveControlUiAuthToken(source); return token ? `Bearer ${token}` : null; } + +// Ordered list of non-empty, header-safe shared-secret candidates. Used by +// 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`. +export function resolveControlUiAuthCandidates(source: ControlUiAuthSource): string[] { + const seen = new Set<string>(); + const out: string[] = []; + for (const raw of [ + normalizeOptionalString(source.hello?.auth?.deviceToken), + normalizeOptionalString(source.settings?.token), + normalizeOptionalString(source.password), + ]) { + const sanitized = sanitizeHeaderToken(raw ?? null); + if (sanitized && !seen.has(sanitized)) { + seen.add(sanitized); + out.push(sanitized); + } + } + return out; +}
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
3- github.com/openclaw/openclaw/commit/2321d67263bc710e357644d59f746b08d891051bnvdPatch
- github.com/openclaw/openclaw/security/advisories/GHSA-93rg-2xm5-2p9vnvdThird Party AdvisoryPatch
- www.vulncheck.com/advisories/openclaw-authentication-bypass-in-gateway-control-ui-bootstrap-config-endpointnvdThird Party AdvisoryPatch
News mentions
0No linked articles in our index yet.