fix(webui): keep credentials out of service worker caches

This commit is contained in:
Xubin Ren
2026-08-12 21:09:29 +09:00
parent e455a2b7fa
commit 5fc8303f9e
6 changed files with 83 additions and 34 deletions
@@ -717,7 +717,7 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
bus, bus,
port=port, port=port,
token="static-token", token="static-token",
tokenIssuePath="/auth/token", tokenIssuePath="/custom-token",
websocketRequiresToken=True, websocketRequiresToken=True,
) )
@@ -725,15 +725,16 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
try: try:
denied = await _http_get(f"http://127.0.0.1:{port}/auth/token") denied = await _http_get(f"http://127.0.0.1:{port}/custom-token")
assert denied.status_code == 401 assert denied.status_code == 401
allowed = await _http_get( allowed = await _http_get(
f"http://127.0.0.1:{port}/auth/token", f"http://127.0.0.1:{port}/custom-token",
headers={"Authorization": "Bearer static-token"}, headers={"Authorization": "Bearer static-token"},
) )
assert allowed.status_code == 200 assert allowed.status_code == 200
assert allowed.json()["token"].startswith("nbwt_") assert allowed.json()["token"].startswith("nbwt_")
assert allowed.headers["Cache-Control"] == "no-store"
finally: finally:
await channel.stop() await channel.stop()
await server_task await server_task
@@ -3803,6 +3804,7 @@ async def test_token_issue_rejects_when_at_capacity(bus: MagicMock) -> None:
headers={"Authorization": "Bearer s"}, headers={"Authorization": "Bearer s"},
) )
assert resp.status_code == 429 assert resp.status_code == 429
assert resp.headers["Cache-Control"] == "no-store"
data = resp.json() data = resp.json()
assert "error" in data assert "error" in data
finally: finally:
@@ -227,6 +227,7 @@ async def test_bootstrap_returns_token_for_localhost(
try: try:
resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap") resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.headers["Cache-Control"] == "no-store"
body = resp.json() body = resp.json()
assert body["token"].startswith("nbwt_") assert body["token"].startswith("nbwt_")
assert channel.gateway.tokens.issued_token_audiences[body["token"]] == "webui" assert channel.gateway.tokens.issued_token_audiences[body["token"]] == "webui"
+3
View File
@@ -100,6 +100,7 @@ def http_json_response(
*, *,
status: int = 200, status: int = 200,
accept_encoding: str | None = None, accept_encoding: str | None = None,
extra_headers: list[tuple[str, str]] | None = None,
) -> Response: ) -> Response:
body = json.dumps(data, ensure_ascii=False).encode("utf-8") body = json.dumps(data, ensure_ascii=False).encode("utf-8")
headers = [ headers = [
@@ -112,6 +113,8 @@ def http_json_response(
if len(body) >= _JSON_GZIP_MIN_BYTES and accepts_gzip(accept_encoding): if len(body) >= _JSON_GZIP_MIN_BYTES and accepts_gzip(accept_encoding):
body = gzip.compress(body, compresslevel=_JSON_GZIP_LEVEL, mtime=0) body = gzip.compress(body, compresslevel=_JSON_GZIP_LEVEL, mtime=0)
headers.append(("Content-Encoding", "gzip")) headers.append(("Content-Encoding", "gzip"))
if extra_headers:
headers.extend(extra_headers)
headers.append(("Content-Length", str(len(body)))) headers.append(("Content-Length", str(len(body))))
reason = http.HTTPStatus(status).phrase reason = http.HTTPStatus(status).phrase
return Response(status, reason, Headers(headers), body) return Response(status, reason, Headers(headers), body)
+13 -4
View File
@@ -121,6 +121,7 @@ from nanobot.webui.workspaces import WebUIWorkspaceController
_SLOW_WEBUI_HTTP_LOG_MS = 1_000 _SLOW_WEBUI_HTTP_LOG_MS = 1_000
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload" _WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request" _WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
_NO_STORE_HEADERS = [("Cache-Control", "no-store")]
_WEBUI_MUTATION_PATHS = { _WEBUI_MUTATION_PATHS = {
"automation.enable": "/api/webui/automations/enable", "automation.enable": "/api/webui/automations/enable",
@@ -547,9 +548,16 @@ class GatewayHTTPHandler:
"too many outstanding issued tokens ({}), rejecting issuance", "too many outstanding issued tokens ({}), rejecting issuance",
len(self.tokens.issued_tokens), len(self.tokens.issued_tokens),
) )
return _http_json_response({"error": "too many outstanding tokens"}, status=429) return _http_json_response(
{"error": "too many outstanding tokens"},
status=429,
extra_headers=_NO_STORE_HEADERS,
)
token_value = self.tokens.issue_token(self.config.token_ttl_s) token_value = self.tokens.issue_token(self.config.token_ttl_s)
return _http_json_response(token_response_payload(token_value, self.config.token_ttl_s)) return _http_json_response(
token_response_payload(token_value, self.config.token_ttl_s),
extra_headers=_NO_STORE_HEADERS,
)
# -- Bootstrap ---------------------------------------------------------- # -- Bootstrap ----------------------------------------------------------
@@ -579,7 +587,7 @@ class GatewayHTTPHandler:
"runtime_surface": self._runtime_surface, "runtime_surface": self._runtime_surface,
"runtime_capabilities": self._capabilities, "runtime_capabilities": self._capabilities,
} }
return _http_json_response(payload) return _http_json_response(payload, extra_headers=_NO_STORE_HEADERS)
api_token_allowed = bool(secret) or is_local_browser api_token_allowed = bool(secret) or is_local_browser
if not self.tokens.can_issue(include_api_token=api_token_allowed): if not self.tokens.can_issue(include_api_token=api_token_allowed):
@@ -587,6 +595,7 @@ class GatewayHTTPHandler:
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"), json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
status=429, status=429,
content_type="application/json; charset=utf-8", content_type="application/json; charset=utf-8",
extra_headers=_NO_STORE_HEADERS,
) )
token = self.tokens.issue_token(self.config.token_ttl_s, audience="webui") token = self.tokens.issue_token(self.config.token_ttl_s, audience="webui")
api_token = ( api_token = (
@@ -611,7 +620,7 @@ class GatewayHTTPHandler:
} }
if api_token is not None: if api_token is not None:
payload["api_token"] = api_token payload["api_token"] = api_token
return _http_json_response(payload) return _http_json_response(payload, extra_headers=_NO_STORE_HEADERS)
def _bootstrap_ws_url(self, request: Any) -> str: def _bootstrap_ws_url(self, request: Any) -> str:
headers = getattr(request, "headers", {}) or {} headers = getattr(request, "headers", {}) or {}
+31 -23
View File
@@ -1,6 +1,23 @@
const CACHE_NAME = "nanobot-static-v1"; const CACHE_PREFIX = "nanobot-static-";
const CACHE_NAME = `${CACHE_PREFIX}v2`;
const ASSET_MANIFEST_PATH = "/asset-manifest.json"; const ASSET_MANIFEST_PATH = "/asset-manifest.json";
const PRECACHE = ["/", "/manifest.json", ASSET_MANIFEST_PATH]; const PRECACHE = ["/", "/manifest.json", ASSET_MANIFEST_PATH];
const NETWORK_FIRST_STATIC_PATHS = new Set([
"/",
"/manifest.json",
ASSET_MANIFEST_PATH,
"/brand/nanobot_apple_touch.png",
"/brand/nanobot_favicon_32.png",
"/brand/nanobot_icon_192.png",
"/brand/nanobot_icon_512.png",
"/brand/nanobot_icon_maskable.png",
"/brand/nanobot_mark.svg",
]);
function responseMayBeCached(response) {
const cacheControl = response.headers?.get("Cache-Control") ?? "";
return response.ok && !/(?:^|,)\s*(?:private|no-cache|no-store)\b/i.test(cacheControl);
}
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
@@ -91,7 +108,7 @@ self.addEventListener("activate", (event) => {
.then((keys) => .then((keys) =>
Promise.all( Promise.all(
keys keys
.filter((k) => k !== CACHE_NAME) .filter((k) => k.startsWith(CACHE_PREFIX) && k !== CACHE_NAME)
.map((k) => caches.delete(k)) .map((k) => caches.delete(k))
) )
) )
@@ -107,28 +124,13 @@ self.addEventListener("fetch", (event) => {
// (never reconstructed), so their credentials mode is preserved and gateway // (never reconstructed), so their credentials mode is preserved and gateway
// auth cookies flow through on every path we touch. WebSocket upgrades are // auth cookies flow through on every path we touch. WebSocket upgrades are
// never dispatched to a service worker's fetch handler, so the WS endpoint // never dispatched to a service worker's fetch handler, so the WS endpoint
// cannot be cached; the /__nanobot exclusion below still protects its HTTP // cannot be cached. Unknown HTTP endpoints are passed through below.
// polling/socket bootstrap endpoints.
if (request.method !== "GET") return; if (request.method !== "GET") return;
if (new URL(request.url).origin !== self.location.origin) return; if (new URL(request.url).origin !== self.location.origin) return;
const url = new URL(request.url); const url = new URL(request.url);
const path = url.pathname; const path = url.pathname;
// Never cache API, auth, WebSocket, HMR, or WebUI endpoint paths. In
// particular /webui/bootstrap issues fresh gateway credentials on every page
// load and must never be cached or replayed offline. The /auth prefix covers
// the default token endpoint; custom token_issue_path values should be kept
// under one of these prefixes.
if (
path.startsWith("/api") ||
path.startsWith("/auth") ||
path.startsWith("/__nanobot") ||
path.startsWith("/webui")
) {
return;
}
// Static assets: cache-first. Only files under /assets/ carry content hashes // Static assets: cache-first. Only files under /assets/ carry content hashes
// (the gateway serves them immutable); brand icons, the favicon and other // (the gateway serves them immutable); brand icons, the favicon and other
// un-hashed files can change between releases and stay on the network-first // un-hashed files can change between releases and stay on the network-first
@@ -138,7 +140,7 @@ self.addEventListener("fetch", (event) => {
caches.match(request).then((cached) => { caches.match(request).then((cached) => {
if (cached) return cached; if (cached) return cached;
return fetch(request).then((response) => { return fetch(request).then((response) => {
if (response.ok) { if (responseMayBeCached(response)) {
const clone = response.clone(); const clone = response.clone();
caches.open(CACHE_NAME).then((c) => c.put(request, clone)); caches.open(CACHE_NAME).then((c) => c.put(request, clone));
} }
@@ -149,22 +151,28 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
// Everything else: network-first (index.html, manifest, brand assets, etc.) // Cache only explicit public files and browser navigations. Dynamic routes
// are deliberately passed through because token_issue_path and extension
// endpoints are configurable and cannot be safely identified by prefixes.
const isNavigation = request.mode === "navigate";
if (!isNavigation && !NETWORK_FIRST_STATIC_PATHS.has(path)) return;
// App shell and public files: network-first with an offline fallback.
const networkResponse = fetch(request); const networkResponse = fetch(request);
event.waitUntil( event.waitUntil(
networkResponse networkResponse
.then(async (response) => { .then(async (response) => {
if (!response.ok) return; if (!responseMayBeCached(response)) return;
// Clone before the first await. The original response is also handed // Clone before the first await. The original response is also handed
// to respondWith(), which may lock its body as soon as this callback // to respondWith(), which may lock its body as soon as this callback
// yields to the event loop. // yields to the event loop.
const cachedResponse = response.clone(); const cachedResponse = response.clone();
const cache = await caches.open(CACHE_NAME); const cache = await caches.open(CACHE_NAME);
await cache.put(request, cachedResponse); await cache.put(isNavigation ? "/" : request, cachedResponse);
// Refresh the complete build graph before pruning. A deployment can // Refresh the complete build graph before pruning. A deployment can
// change index.html without changing sw.js, so this cannot rely only // change index.html without changing sw.js, so this cannot rely only
// on the manifest cached when the worker was installed. // on the manifest cached when the worker was installed.
if (path === "/") { if (isNavigation || path === "/") {
if (await refreshAssetManifest(cache)) await pruneStaleEntries(); if (await refreshAssetManifest(cache)) await pruneStaleEntries();
} }
}) })
+30 -4
View File
@@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const SW_SCRIPT = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8"); const SW_SCRIPT = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8");
const ORIGIN = "https://nanobot.test"; const ORIGIN = "https://nanobot.test";
const CACHE_NAME = "nanobot-static-v1"; const CACHE_NAME = "nanobot-static-v2";
/** Minimal Cache-compatible in-memory store with SW-style URL normalization. */ /** Minimal Cache-compatible in-memory store with SW-style URL normalization. */
class FakeCacheStore { class FakeCacheStore {
@@ -66,7 +66,7 @@ function loadSw(): LoadedSw {
const caches = { const caches = {
open: vi.fn(async () => store), open: vi.fn(async () => store),
match: vi.fn((input: Request | string) => store.match(input)), match: vi.fn((input: Request | string) => store.match(input)),
keys: vi.fn(async () => [CACHE_NAME, "nanobot-static-v0"]), keys: vi.fn(async () => [CACHE_NAME, "nanobot-static-v1", "other-app-cache"]),
delete: vi.fn(async (name: string) => { delete: vi.fn(async (name: string) => {
deletedCacheNames.push(name); deletedCacheNames.push(name);
return true; return true;
@@ -149,7 +149,7 @@ describe("service worker", () => {
await sw.fire("activate"); await sw.fire("activate");
expect(sw.deletedCacheNames).toEqual(["nanobot-static-v0"]); expect(sw.deletedCacheNames).toEqual(["nanobot-static-v1"]);
expect(sw.claimMock).toHaveBeenCalledTimes(1); expect(sw.claimMock).toHaveBeenCalledTimes(1);
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true);
@@ -161,7 +161,7 @@ describe("service worker", () => {
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.css`)).toBe(false); expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.css`)).toBe(false);
}); });
it("does not intercept API, auth, WebSocket, or WebUI endpoint requests", async () => { it("does not intercept dynamic or unknown endpoint requests", async () => {
const sw = loadSw(); const sw = loadSw();
const paths = [ const paths = [
"/api/v1/models", "/api/v1/models",
@@ -171,6 +171,8 @@ describe("service worker", () => {
"/api/chat/completions", "/api/chat/completions",
"/webui/bootstrap", "/webui/bootstrap",
"/webui/session/list", "/webui/session/list",
"/custom-token",
"/mcp-oauth/callback",
]; ];
for (const path of paths) { for (const path of paths) {
@@ -229,6 +231,30 @@ describe("service worker", () => {
expect(sw.store.entries.has(assetUrl)).toBe(true); expect(sw.store.entries.has(assetUrl)).toBe(true);
}); });
it.each(["private", "no-cache", "no-store"])(
"does not cache a %s response even under the static asset prefix",
async (cacheControl) => {
const sw = loadSw();
const tokenUrl = `${ORIGIN}/assets/custom-token`;
const originalRequest = new Request(tokenUrl);
sw.fetchMock.mockResolvedValue(
new Response('{"token":"short-lived"}', {
headers: {
"Cache-Control": cacheControl,
"Content-Type": "application/json",
},
}),
);
const event = { request: originalRequest, respondWith: vi.fn() };
await sw.fire("fetch", event);
const response = (await event.respondWith.mock.calls[0][0]) as Response;
expect(await response.json()).toEqual({ token: "short-lived" });
expect(sw.store.entries.has(tokenUrl)).toBe(false);
},
);
it("keeps un-hashed brand assets on the network-first path", async () => { it("keeps un-hashed brand assets on the network-first path", async () => {
const sw = loadSw(); const sw = loadSw();
const iconUrl = `${ORIGIN}/brand/nanobot_icon_192.png`; const iconUrl = `${ORIGIN}/brand/nanobot_icon_192.png`;