fix(webui): gate bootstrap API token issuance

This commit is contained in:
chengyongru
2026-07-08 21:01:48 +08:00
committed by Xubin Ren
parent 7204d88a4c
commit 88143a8bf0
16 changed files with 219 additions and 85 deletions
+5 -4
View File
@@ -24,6 +24,7 @@ import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import {
clearSavedSecret,
consumeUrlBootstrapSecret,
deriveWsUrl,
fetchBootstrap,
loadSavedSecret,
@@ -361,14 +362,14 @@ export default function App() {
current.status === "ready" && current.client === client
? {
...current,
token: boot.token,
token: boot.api_token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
runtimeSurface,
}
: current,
);
return { token: boot.token, url };
return { token: boot.api_token, url };
},
[],
);
@@ -402,7 +403,7 @@ export default function App() {
setState({
status: "ready",
client,
token: boot.token,
token: boot.api_token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null,
runtimeSurface,
@@ -441,7 +442,7 @@ export default function App() {
}, [refreshReadyClient, state]);
useEffect(() => {
const saved = loadSavedSecret();
const saved = consumeUrlBootstrapSecret() || loadSavedSecret();
return bootstrapWithSecret(saved);
}, [bootstrapWithSecret]);
+27
View File
@@ -2,6 +2,7 @@ import type { BootstrapResponse } from "./types";
import { fetchWithTimeout } from "./http";
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
const URL_SECRET_PARAM = "bootstrapSecret";
/** Read a previously saved bootstrap secret from localStorage. */
export function loadSavedSecret(): string {
@@ -31,6 +32,29 @@ export function clearSavedSecret(): void {
}
}
export function consumeUrlBootstrapSecret(): string {
if (typeof window === "undefined") return "";
const hash = window.location.hash || "";
const queryStart = hash.indexOf("?");
if (queryStart < 0) return "";
const path = hash.slice(0, queryStart) || "#/";
const query = hash.slice(queryStart + 1);
const params = new URLSearchParams(query);
const secret = params.get(URL_SECRET_PARAM)?.trim() || "";
if (!secret) return "";
params.delete(URL_SECRET_PARAM);
const nextQuery = params.toString();
const nextHash = `${path}${nextQuery ? `?${nextQuery}` : ""}`;
window.history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}${nextHash}`,
);
return secret;
}
/**
* Fetch a short-lived token + the WebSocket path from the gateway's
* ``/webui/bootstrap`` endpoint.
@@ -56,6 +80,9 @@ export async function fetchBootstrap(
if (!body.token || !body.ws_path) {
throw new Error("bootstrap response missing token or ws_path");
}
if (!body.api_token) {
throw new Error("bootstrap response missing api_token");
}
return body;
}
+1
View File
@@ -296,6 +296,7 @@ export interface SidebarStatePayload {
export interface BootstrapResponse {
token: string;
api_token: string;
ws_path: string;
ws_url?: string | null;
expires_in: number;
+6
View File
@@ -177,10 +177,12 @@ vi.mock("@/hooks/useTheme", async () => {
vi.mock("@/lib/bootstrap", () => ({
fetchBootstrap: vi.fn().mockResolvedValue({
token: "tok",
api_token: "api-tok",
ws_path: "/",
expires_in: 300,
}),
deriveWsUrl: vi.fn(() => "ws://test"),
consumeUrlBootstrapSecret: vi.fn(() => ""),
loadSavedSecret: vi.fn(() => ""),
saveSecret: vi.fn(),
clearSavedSecret: vi.fn(),
@@ -239,6 +241,7 @@ describe("App layout", () => {
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
api_token: "api-tok",
ws_path: "/",
expires_in: 300,
});
@@ -723,6 +726,7 @@ describe("App layout", () => {
];
vi.mocked(fetchBootstrap).mockResolvedValue({
token: "tok",
api_token: "api-tok",
ws_path: "/",
expires_in: 300,
runtime_surface: "native",
@@ -2147,11 +2151,13 @@ describe("App layout", () => {
vi.mocked(fetchBootstrap)
.mockResolvedValueOnce({
token: "tok-1",
api_token: "api-tok-1",
ws_path: "/",
expires_in: 30,
})
.mockResolvedValueOnce({
token: "tok-2",
api_token: "api-tok-2",
ws_path: "/",
expires_in: 300,
});
+26 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import { consumeUrlBootstrapSecret, deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
describe("bootstrap helpers", () => {
afterEach(() => {
@@ -50,4 +50,29 @@ describe("bootstrap helpers", () => {
await pending;
});
it("rejects bootstrap responses without an API token", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({ token: "ws-token", ws_path: "/", expires_in: 300 }),
})),
);
await expect(fetchBootstrap()).rejects.toThrow(
"bootstrap response missing api_token",
);
});
it("consumes bootstrap secrets from the URL fragment", () => {
window.history.replaceState(
null,
"",
"/#/settings?bootstrapSecret=s3cret&section=models",
);
expect(consumeUrlBootstrapSecret()).toBe("s3cret");
expect(window.location.hash).toBe("#/settings?section=models");
});
});