mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 15:19:16 +03:00
fix(webui): keep credentials out of service worker caches
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
Reference in New Issue
Block a user