mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-18 18:16:38 +03:00
fix(webui): keep credentials out of service worker caches
This commit is contained in:
+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 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) => {
|
||||
event.waitUntil(
|
||||
@@ -91,7 +108,7 @@ self.addEventListener("activate", (event) => {
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== CACHE_NAME)
|
||||
.filter((k) => k.startsWith(CACHE_PREFIX) && k !== CACHE_NAME)
|
||||
.map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
@@ -107,28 +124,13 @@ self.addEventListener("fetch", (event) => {
|
||||
// (never reconstructed), so their credentials mode is preserved and gateway
|
||||
// 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
|
||||
// cannot be cached; the /__nanobot exclusion below still protects its HTTP
|
||||
// polling/socket bootstrap endpoints.
|
||||
// cannot be cached. Unknown HTTP endpoints are passed through below.
|
||||
if (request.method !== "GET") return;
|
||||
if (new URL(request.url).origin !== self.location.origin) return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
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
|
||||
// (the gateway serves them immutable); brand icons, the favicon and other
|
||||
// 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) => {
|
||||
if (cached) return cached;
|
||||
return fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
if (responseMayBeCached(response)) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
|
||||
}
|
||||
@@ -149,22 +151,28 @@ self.addEventListener("fetch", (event) => {
|
||||
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);
|
||||
event.waitUntil(
|
||||
networkResponse
|
||||
.then(async (response) => {
|
||||
if (!response.ok) return;
|
||||
if (!responseMayBeCached(response)) return;
|
||||
// Clone before the first await. The original response is also handed
|
||||
// to respondWith(), which may lock its body as soon as this callback
|
||||
// yields to the event loop.
|
||||
const cachedResponse = response.clone();
|
||||
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
|
||||
// change index.html without changing sw.js, so this cannot rely only
|
||||
// on the manifest cached when the worker was installed.
|
||||
if (path === "/") {
|
||||
if (isNavigation || path === "/") {
|
||||
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 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. */
|
||||
class FakeCacheStore {
|
||||
@@ -66,7 +66,7 @@ function loadSw(): LoadedSw {
|
||||
const caches = {
|
||||
open: vi.fn(async () => store),
|
||||
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) => {
|
||||
deletedCacheNames.push(name);
|
||||
return true;
|
||||
@@ -149,7 +149,7 @@ describe("service worker", () => {
|
||||
|
||||
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.store.entries.has(`${ORIGIN}/`)).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);
|
||||
});
|
||||
|
||||
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 paths = [
|
||||
"/api/v1/models",
|
||||
@@ -171,6 +171,8 @@ describe("service worker", () => {
|
||||
"/api/chat/completions",
|
||||
"/webui/bootstrap",
|
||||
"/webui/session/list",
|
||||
"/custom-token",
|
||||
"/mcp-oauth/callback",
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
@@ -229,6 +231,30 @@ describe("service worker", () => {
|
||||
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 () => {
|
||||
const sw = loadSw();
|
||||
const iconUrl = `${ORIGIN}/brand/nanobot_icon_192.png`;
|
||||
|
||||
Reference in New Issue
Block a user