diff --git a/.env.production.example b/.env.production.example index 0d5f7a2c5..06ff00f4b 100644 --- a/.env.production.example +++ b/.env.production.example @@ -521,10 +521,16 @@ API_TOKEN_HMAC_KEY="" # LOG_SLOW_THRESHOLD_MS="3000" # LOG_INCLUDE_STACK="false" -# Optional log shipping to a Loki instance. LOKI_ENDPOINT is the push URL and -# is what turns shipping on; the username/password pair is only needed when -# your Loki sits behind basic auth. Leave all three unset and logs stay on -# stdout for the Docker log driver to rotate. +# Optional log shipping to a Loki instance. LOKI_ENDPOINT turns shipping on. +# Either form works: the base URL (http://loki:3100) or the full push URL +# (http://loki:3100/loki/api/v1/push). A Loki on your own network is fine, +# private addresses and internal hostnames included; only link-local and +# cloud-metadata addresses are refused. The username/password pair is only +# needed when your Loki sits behind basic auth. When a push fails (Loki down, +# wrong path, rejected credentials) the app writes one line to stderr naming +# the Loki origin, the reason and how many events were dropped, at most once +# every five minutes per reason; the password never appears in it. Leave all +# three unset and logs stay on stdout for the Docker log driver to rotate. # LOKI_ENDPOINT="" # LOKI_USERNAME="" # LOKI_PASSWORD="" diff --git a/docker-compose.yml b/docker-compose.yml index e55a024af..2ad99006b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -345,10 +345,11 @@ x-healthlog-env: &healthlog_env LOG_SAMPLE_RATE: "${LOG_SAMPLE_RATE:-}" LOG_SLOW_THRESHOLD_MS: "${LOG_SLOW_THRESHOLD_MS:-}" LOG_INCLUDE_STACK: "${LOG_INCLUDE_STACK:-}" - # Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the push - # URL to turn it on; the username/password pair is only needed when your - # Loki is behind basic auth. Leave all three unset and logs stay on stdout - # for the Docker log driver to rotate. + # Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the base + # URL or the full push URL to turn it on; a Loki on your own network works. + # The username/password pair is only needed when your Loki is behind basic + # auth. Push failures show up as a rate-limited line on stderr. Leave all + # three unset and logs stay on stdout for the Docker log driver to rotate. LOKI_ENDPOINT: "${LOKI_ENDPOINT:-}" LOKI_USERNAME: "${LOKI_USERNAME:-}" LOKI_PASSWORD: "${LOKI_PASSWORD:-}" diff --git a/public/sw.js b/public/sw.js index 8c4284924..67c604453 100644 --- a/public/sw.js +++ b/public/sw.js @@ -61,6 +61,21 @@ const CURRENT_HEALTHLOG_CACHES = new Set([ PAGE_CACHE, DATA_CACHE, ]); +// Presence marker `src/proxy.ts` sets on every HealthLog page response. See +// `isForeignNavigationResponse` for why the worker needs it and the proxy for +// why it carries no version. Keep the name in lockstep with +// `HEALTHLOG_PAGE_MARKER_HEADER` there. +const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page"; +// Set as soon as a navigation looks like another app (see +// `isForeignNavigationResponse`). While it is set the fetch handler declines +// every request, so the page and its assets come from the network. It is +// cleared again when `confirmForeignOrigin` finds HealthLog after all, and +// stays set for the rest of the worker's life when the origin is confirmed +// foreign and the worker retires. +let foreignOrigin = false; +// One confirmation at a time: a burst of navigations must not fire a burst of +// probes, or race one probe's "HealthLog after all" against another's retire. +let foreignOriginCheck = null; const MAX_STATIC_ENTRIES = 150; const MAX_PAGE_ENTRIES = 30; const MAX_DATA_ENTRIES = 60; @@ -168,6 +183,10 @@ self.addEventListener("activate", (event) => { // ── Fetch: strategy per resource type ──────────────────────────────────────── self.addEventListener("fetch", (event) => { + // The origin serves another app now (see `retireFromForeignOrigin`): no + // `respondWith`, no cache, the browser's own fetch for everything. + if (foreignOrigin) return; + const { request } = event; const url = new URL(request.url); @@ -335,6 +354,134 @@ function isCacheableNavigation(request, response) { return true; } +/** + * Does this navigation response come from something other than HealthLog? + * + * The worker is registered with scope `/` because HealthLog serves everything + * from `/`. When HealthLog is stopped and a different app starts on the same + * origin (the usual case is `localhost:3000` on a developer machine), this + * worker kept answering: `/_next/static/*` is cache-first, so the other app + * ran on HealthLog's cached chunks, and the only way out was unregistering by + * hand in the browser's developer tools. + * + * Only a response that positively looks like another app counts: + * + * - a redirect is opaque to the worker (`opaqueredirect`, status 0, no + * readable headers), and HealthLog redirects every signed-out page, so it + * proves nothing; status 0 fails the `ok` check below; + * - a non-2xx answer proves nothing either: a reverse proxy or auth gateway + * in front of a stopped HealthLog answers 502 or 401 without the marker, + * and unregistering there would also drop the Web Push subscription; + * - a network failure never reaches this check (it throws into the offline + * fallback in `networkFirst`). + * + * What is left is a successful page without the marker HealthLog's proxy puts + * on every page response. That is a suspicion, not a verdict: a reverse proxy + * with a response-header allowlist strips the marker from every genuine page. + * `confirmForeignOrigin` settles it before anything is deleted. + */ +function isForeignNavigationResponse(response) { + if (!response || !response.ok) return false; + return !response.headers.has(HEALTHLOG_PAGE_MARKER_HEADER); +} + +/** + * Is the origin positively not HealthLog? Asked of `/api/version`, which is + * public (on the proxy's `PUBLIC_PATHS` list, no session needed) and answers + * `{ data: { version, buildSha, ... } }`. A body check survives any proxy that + * rewrites headers. + * + * Retire only on a positive answer from something else: a 404, a 2xx that is + * not JSON (another app's HTML fallback), or JSON without the HealthLog shape. + * `buildSha` is `null` on an image not built by the release workflow, so the + * shape is "`version` is a string and `buildSha` is present", not "both are + * strings". Anything that is not an answer (a thrown fetch, a 5xx, a 401 from + * an auth gateway, a redirect) proves nothing, and the worker resumes normal + * service. + * + * The probe is the worker's own `fetch`, which never passes through this + * worker's fetch handler; `no-store` keeps the HTTP cache out of it and + * `redirect: "manual"` keeps a login redirect from reading as an answer. + */ +async function confirmForeignOrigin() { + let response; + try { + response = await fetch("/api/version", { + cache: "no-store", + redirect: "manual", + credentials: "omit", + }); + } catch { + return false; + } + if (response.status === 404) return true; + if (!response.ok) return false; + let body; + try { + body = JSON.parse(await response.text()); + } catch { + return true; + } + const data = body && typeof body === "object" ? body.data : null; + const isHealthlog = + !!data && + typeof data === "object" && + typeof data.version === "string" && + "buildSha" in data; + return !isHealthlog; +} + +/** + * Confirm, then either retire or resume. Shared by concurrent navigations. + */ +function checkForeignOrigin() { + if (!foreignOriginCheck) { + foreignOriginCheck = (async () => { + let foreign = false; + try { + foreign = await confirmForeignOrigin(); + } catch { + foreign = false; + } + if (foreign) { + await retireFromForeignOrigin(); + return; + } + // HealthLog after all, or no answer: serve normally again, and let a + // later suspicious navigation ask again. + foreignOrigin = false; + foreignOriginCheck = null; + })(); + } + return foreignOriginCheck; +} + +/** + * Leave an origin that no longer serves HealthLog: delete every HealthLog + * cache (only ours, matched by name, never another app's CacheStorage) and + * unregister, so the next load of that origin runs without this worker. The + * `foreignOrigin` flag set by the caller covers the requests the current page + * still makes before it unloads. Each step is best-effort and independent: a + * failed cache delete must not keep the worker registered. + */ +async function retireFromForeignOrigin() { + try { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k)) + .map((k) => caches.delete(k)), + ); + } catch { + // CacheStorage unavailable; unregistering still matters more. + } + try { + await self.registration.unregister(); + } catch { + // Nothing further to do from inside the worker. + } +} + /** * Network-first: try network (preferring the navigation-preload response * when the browser already started it), fall back to cache. @@ -351,6 +498,13 @@ async function networkFirst(event, cacheName) { const response = (event.preloadResponse ? await event.preloadResponse : null) || (await fetch(request)); + if (isForeignNavigationResponse(response)) { + // Step aside at once (declining a request only sends it to the + // network), but delete nothing until the origin is confirmed foreign. + foreignOrigin = true; + event.waitUntil(checkForeignOrigin()); + return response; + } if (response.ok && isCacheableNavigation(request, response)) { event.waitUntil( bestEffortCacheWrite( diff --git a/src/__tests__/proxy-page-marker.test.ts b/src/__tests__/proxy-page-marker.test.ts new file mode 100644 index 000000000..9c4c897bc --- /dev/null +++ b/src/__tests__/proxy-page-marker.test.ts @@ -0,0 +1,72 @@ +/** + * The page marker `public/sw.js` uses to recognise that its origin still + * serves HealthLog (#847). A successful navigation without it makes the + * worker delete its caches and unregister, so the marker has to be on every + * page the proxy lets through, signed in or not, and it must never carry a + * version. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { HEALTHLOG_PAGE_MARKER_HEADER, proxy } from "../proxy"; + +beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function request(pathname: string, signedIn = true): NextRequest { + return new NextRequest(`http://localhost${pathname}`, { + headers: signedIn + ? { cookie: "healthlog_session=sess-1", accept: "text/html" } + : { accept: "text/html" }, + }); +} + +describe("proxy.ts page marker for the service worker", () => { + it.each(["/", "/measurements", "/settings/profile", "/c/hls_abc123"])( + "marks the signed-in page %s", + (path) => { + const res = proxy(request(path)); + expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1"); + }, + ); + + it.each(["/auth/login", "/privacy", "/onboarding"])( + "marks the public page %s", + (path) => { + const res = proxy(request(path, false)); + expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1"); + }, + ); + + it.each(["/api/version", "/api/measurements", "/api/auth/me"])( + "leaves the API response %s unmarked", + (path) => { + const res = proxy(request(path)); + expect(res.headers.has(HEALTHLOG_PAGE_MARKER_HEADER)).toBe(false); + }, + ); + + it("carries presence only, never a version or build id", () => { + vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "9.9.9"); + const res = proxy(request("/")); + expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1"); + }); + + it("uses the header name public/sw.js checks", () => { + const sw = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8"); + expect(sw).toContain( + `const HEALTHLOG_PAGE_MARKER_HEADER = "${HEALTHLOG_PAGE_MARKER_HEADER}";`, + ); + }); +}); diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index ec749ab7b..0ec33c658 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -14,6 +14,11 @@ * `event.preloadResponse` when the browser supplies it; * 4. `trimCache` reads the key list once and deletes the excess prefix * in a single pass (the previous loop re-fetched all keys per delete). + * + * A HealthLog page response carries the `X-HealthLog-Page` marker the proxy + * sets, so the navigation fixtures below build pages with `healthlogPage()`. + * A successful page without it is another app on the same origin (#847), + * which the "foreign origin" block covers. */ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; @@ -38,6 +43,15 @@ const CURRENT_PAGE_CACHE = `healthlog-pages-${FALLBACK_VERSION}`; const CURRENT_STATIC_CACHE = `healthlog-static-${FALLBACK_VERSION}`; const CURRENT_DATA_CACHE = `healthlog-data-${FALLBACK_VERSION}`; +const PAGE_MARKER = "X-HealthLog-Page"; + +/** A page response as HealthLog's proxy delivers it: marker included. */ +function healthlogPage(body: string, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + headers.set(PAGE_MARKER, "1"); + return new Response(body, { ...init, headers }); +} + class FakeCache { map = new Map(); keysCalls = 0; @@ -121,6 +135,7 @@ interface SwHarness { listeners: Map void>; cacheStorage: FakeCacheStorage; navPreload: { enabled: boolean; enable: () => Promise }; + unregisterCalls: { count: number }; lifetimePromises: Promise[]; context: Record; } @@ -129,6 +144,7 @@ function bootServiceWorker(): SwHarness { const listeners = new Map void>(); const cacheStorage = new FakeCacheStorage(); const lifetimePromises: Promise[] = []; + const unregisterCalls = { count: 0 }; const navPreload = { enabled: false, enable: async () => { @@ -147,6 +163,10 @@ function bootServiceWorker(): SwHarness { clients: { claim: async () => {} }, registration: { navigationPreload: navPreload, + unregister: async () => { + unregisterCalls.count += 1; + return true; + }, showNotification: async (title: string, opts: { tag?: string }) => { shown.push({ title, tag: opts?.tag, closed: false }); }, @@ -194,7 +214,14 @@ function bootServiceWorker(): SwHarness { }; vm.createContext(context); vm.runInContext(SW_SOURCE, context); - return { listeners, cacheStorage, navPreload, lifetimePromises, context }; + return { + listeners, + cacheStorage, + navPreload, + unregisterCalls, + lifetimePromises, + context, + }; } async function dispatchActivate(harness: SwHarness): Promise { @@ -389,7 +416,7 @@ describe("sw.js — best-effort cache writes", () => { const harness = bootServiceWorker(); const pageCache = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); pageCache.putError = new Error("CacheStorage quota exceeded"); - const networkResponse = new Response("network shell", { + const networkResponse = healthlogPage("network shell", { headers: { "X-Network-Response": "original" }, }); harness.context.fetch = async () => networkResponse; @@ -413,7 +440,7 @@ describe("sw.js — best-effort cache writes", () => { notifyPutStarted = resolve; }); pageCache.onPut = notifyPutStarted; - const networkResponse = new Response("network shell"); + const networkResponse = healthlogPage("network shell"); harness.context.fetch = async () => networkResponse; const responsePromise = dispatchNavigationFetch(harness, "/measurements"); @@ -572,7 +599,7 @@ describe("sw.js — networkFirst offline fallback", () => { const res = await dispatchNavigationFetch( harness, "/", - Promise.resolve(new Response("preloaded shell")), + Promise.resolve(healthlogPage("preloaded shell")), ); expect(await res.clone().text()).toBe("preloaded shell"); @@ -587,7 +614,7 @@ describe("sw.js — networkFirst privacy gate", () => { it("does not cache a navigation response that carries Cache-Control: no-store", async () => { const harness = bootServiceWorker(); harness.context.fetch = async () => - new Response("private shell", { + healthlogPage("private shell", { headers: { "Cache-Control": "no-store" }, }); @@ -600,7 +627,7 @@ describe("sw.js — networkFirst privacy gate", () => { it("does not cache the /c/ clinician-share view even without no-store", async () => { const harness = bootServiceWorker(); - harness.context.fetch = async () => new Response("share shell"); + harness.context.fetch = async () => healthlogPage("share shell"); const res = await dispatchNavigationFetch(harness, "/c/hls_abc123"); expect(await res.clone().text()).toBe("share shell"); @@ -611,7 +638,7 @@ describe("sw.js — networkFirst privacy gate", () => { it("still caches an ordinary navigation response", async () => { const harness = bootServiceWorker(); - harness.context.fetch = async () => new Response("app shell"); + harness.context.fetch = async () => healthlogPage("app shell"); const res = await dispatchNavigationFetch(harness, "/measurements"); expect(await res.clone().text()).toBe("app shell"); @@ -621,6 +648,375 @@ describe("sw.js — networkFirst privacy gate", () => { }); }); +/** + * Dispatch any request and report whether the worker took it. Used where the + * assertion IS whether `respondWith` was called: a declined request goes to + * the network through the browser, untouched by this worker. + */ +function dispatchAnyFetch( + harness: SwHarness, + path: string, + headers: Record = {}, +): { responded: boolean; response: Promise | null } { + let captured: Promise | null = null; + let responded = false; + harness.listeners.get("fetch")!({ + request: new Request(`${ORIGIN}${path}`, { headers }), + respondWith: (p: Promise) => { + responded = true; + captured = p; + }, + waitUntil: (p: Promise) => { + harness.lifetimePromises.push(p); + }, + }); + return { responded, response: captured }; +} + +const HEALTHLOG_VERSION_BODY = JSON.stringify({ + data: { version: "1.38.21", buildSha: null, builtAt: null }, + error: null, +}); + +/** + * Route the harness `fetch`: `/api/version` (the worker's confirmation probe) + * gets `version`, everything else gets `page`. Records every requested path. + */ +function routeFetch( + harness: SwHarness, + page: () => Response, + version: () => Response | Promise, +): string[] { + const calls: string[] = []; + harness.context.fetch = async (input: RequestInfo | URL) => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, + ORIGIN, + ); + calls.push(url.pathname); + return url.pathname === "/api/version" ? version() : page(); + }; + return calls; +} + +const foreignHtml = () => + new Response("another app", { + headers: { "Content-Type": "text/html" }, + }); + +async function seedHealthlogCaches(harness: SwHarness): Promise { + const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE); + await statics.put( + `${ORIGIN}/_next/static/chunks/app.js`, + new Response("healthlog chunk"), + ); + const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + await pages.put(`${ORIGIN}/`, healthlogPage("healthlog shell")); + const legacy = await harness.cacheStorage.open("healthlog-data-v0.0.1"); + await legacy.put(`${ORIGIN}/api/version`, new Response("{}")); + // CacheStorage the other app owns on the same origin. + const other = await harness.cacheStorage.open("next-app-runtime"); + await other.put(`${ORIGIN}/x`, new Response("theirs")); +} + +describe("sw.js — foreign origin (#847)", () => { + it("keeps serving and caching while the page carries the HealthLog marker", async () => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + harness.context.fetch = async () => healthlogPage("fresh shell"); + + const res = await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(await res.text()).toBe("fresh shell"); + expect(harness.unregisterCalls.count).toBe(0); + expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true); + const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js"); + expect(asset.responded).toBe(true); + expect(await (await asset.response!).text()).toBe("healthlog chunk"); + }); + + it("retires when the page lacks the marker and the version probe finds another app's HTML", async () => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + const foreignPage = foreignHtml(); + const calls = routeFetch(harness, () => foreignPage, foreignHtml); + + const res = await dispatchNavigationFetch(harness, "/"); + + // The other app's page is returned untouched, and the worker declines + // requests at once, before the probe has answered. + expect(res).toBe(foreignPage); + expect( + dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded, + ).toBe(false); + + await Promise.all(harness.lifetimePromises); + + expect(calls).toEqual(["/", "/api/version"]); + expect(harness.unregisterCalls.count).toBe(1); + // Every HealthLog cache is gone, current and legacy; the other app's is not. + expect([...harness.cacheStorage.stores.keys()]).toEqual([ + "next-app-runtime", + ]); + + // Still declining after the retire: static, HTML and API all go to the + // network through the browser. + expect( + dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded, + ).toBe(false); + expect( + dispatchAnyFetch(harness, "/about", { accept: "text/html" }).responded, + ).toBe(false); + expect(dispatchAnyFetch(harness, "/api/measurements").responded).toBe( + false, + ); + }); + + it("retires when the version probe answers 404", async () => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + routeFetch( + harness, + foreignHtml, + () => new Response("Not Found", { status: 404 }), + ); + + await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(harness.unregisterCalls.count).toBe(1); + expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(false); + }); + + it("retires when the version probe answers JSON without the HealthLog shape", async () => { + const harness = bootServiceWorker(); + routeFetch( + harness, + foreignHtml, + () => new Response(JSON.stringify({ version: "3.0.0" })), + ); + + await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(harness.unregisterCalls.count).toBe(1); + }); + + it("probes with no-store and a manual redirect", async () => { + const harness = bootServiceWorker(); + let probeInit: RequestInit | undefined; + harness.context.fetch = async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + const path = new URL( + String(input instanceof Request ? input.url : input), + ORIGIN, + ).pathname; + if (path === "/api/version") { + probeInit = init; + return new Response(HEALTHLOG_VERSION_BODY); + } + return foreignHtml(); + }; + + await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(probeInit?.cache).toBe("no-store"); + expect(probeInit?.redirect).toBe("manual"); + }); + + it("does not retire when a header-stripping proxy hides the marker from a real HealthLog", async () => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + // The page is HealthLog's, minus the marker a proxy allowlist removed. + routeFetch( + harness, + () => new Response("healthlog page without marker"), + () => + new Response(HEALTHLOG_VERSION_BODY, { + headers: { "Content-Type": "application/json" }, + }), + ); + + const res = await dispatchNavigationFetch(harness, "/"); + expect(await res.text()).toBe("healthlog page without marker"); + await Promise.all(harness.lifetimePromises); + + expect(harness.unregisterCalls.count).toBe(0); + expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true); + // Normal service resumed: the cached chunk is served cache-first again. + const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js"); + expect(asset.responded).toBe(true); + expect(await (await asset.response!).text()).toBe("healthlog chunk"); + }); + + it.each([ + [ + "the probe throws (network)", + () => Promise.reject(new TypeError("network down")), + ], + ["the probe answers 500", () => new Response("boom", { status: 500 })], + [ + "the probe answers 401 from an auth gateway", + () => new Response("Unauthorized", { status: 401 }), + ], + ])( + "does not retire when %s, and resumes service", + async (_label, version) => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + routeFetch(harness, foreignHtml, version as () => Promise); + + await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(harness.unregisterCalls.count).toBe(0); + expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true); + expect( + dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded, + ).toBe(true); + }, + ); + + it("asks again on a later suspicious navigation after an inconclusive probe", async () => { + const harness = bootServiceWorker(); + let probeStatus = 503; + const calls = routeFetch(harness, foreignHtml, () => + probeStatus === 503 + ? new Response("down", { status: 503 }) + : new Response("Not Found", { status: 404 }), + ); + + await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + expect(harness.unregisterCalls.count).toBe(0); + + probeStatus = 404; + await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(calls.filter((p) => p === "/api/version")).toHaveLength(2); + expect(harness.unregisterCalls.count).toBe(1); + }); + + it("recognises a foreign page delivered through navigation preload", async () => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + const calls = routeFetch( + harness, + () => healthlogPage("unexpected"), + () => new Response("Not Found", { status: 404 }), + ); + + const res = await dispatchNavigationFetch( + harness, + "/", + Promise.resolve(new Response("another app")), + ); + await Promise.all(harness.lifetimePromises); + + expect(await res.text()).toBe("another app"); + // The page came from the preload; the only network call is the probe. + expect(calls).toEqual(["/api/version"]); + expect(harness.unregisterCalls.count).toBe(1); + }); + + it("still unregisters when deleting the caches fails", async () => { + const harness = bootServiceWorker(); + harness.cacheStorage.keys = async () => { + throw new Error("CacheStorage unavailable"); + }; + routeFetch(harness, foreignHtml, foreignHtml); + + await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(harness.unregisterCalls.count).toBe(1); + }); + + it("keeps the offline fallback when the network fails (HealthLog is down, not replaced)", async () => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + // Default harness fetch throws: network down. + + const res = await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(await res.text()).toBe("healthlog shell"); + expect(harness.unregisterCalls.count).toBe(0); + expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true); + }); + + it.each([ + [ + "an error page from HealthLog itself", + healthlogPage("boom", { status: 500 }), + ], + [ + "a gateway error from a proxy in front of a stopped HealthLog", + new Response("Bad Gateway", { status: 502 }), + ], + [ + "a 401 from an auth gateway", + new Response("Unauthorized", { status: 401 }), + ], + ])("does not treat %s as foreign", async (_label, response) => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + harness.context.fetch = async () => response; + + const res = await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(res).toBe(response); + expect(harness.unregisterCalls.count).toBe(0); + expect( + dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded, + ).toBe(true); + }); + + it("does not treat an opaque redirect (a signed-out HealthLog page) as foreign", async () => { + const harness = bootServiceWorker(); + const redirect = Response.redirect(`${ORIGIN}/auth/login`, 307); + // A navigation fetched with `redirect: "manual"` surfaces as an opaque + // redirect in a real worker; model the type the worker sees. + Object.defineProperty(redirect, "type", { value: "opaqueredirect" }); + Object.defineProperty(redirect, "headers", { value: new Headers() }); + Object.defineProperty(redirect, "ok", { value: false }); + harness.context.fetch = async () => redirect; + + await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(harness.unregisterCalls.count).toBe(0); + }); + + it("leaves the one-shot auth navigation path unchanged", async () => { + const harness = bootServiceWorker(); + let fetchCalls = 0; + harness.context.fetch = async () => { + fetchCalls += 1; + return new Response("network callback"); + }; + + const { responded, response } = dispatchAuthNavigation( + harness, + "/api/auth/oidc/callback?code=abc&state=xyz", + Promise.resolve(new Response("preloaded callback")), + ); + + expect(responded).toBe(true); + expect(await (await response!).text()).toBe("preloaded callback"); + expect(fetchCalls).toBe(0); + // An auth response has no page marker and must not retire the worker. + await Promise.all(harness.lifetimePromises); + expect(harness.unregisterCalls.count).toBe(0); + }); +}); + describe("sw.js — trimCache", () => { it("reads the key list once and deletes only the oldest excess entries", async () => { const harness = bootServiceWorker(); diff --git a/src/lib/logging/__tests__/transports-loki.test.ts b/src/lib/logging/__tests__/transports-loki.test.ts new file mode 100644 index 000000000..a234d8aa4 --- /dev/null +++ b/src/lib/logging/__tests__/transports-loki.test.ts @@ -0,0 +1,352 @@ +/** + * Loki shipping (#947 follow-up): the endpoint forms an operator types, the + * dial path a private Loki needs, the floor no configuration opens, and the + * stderr notice that replaces the silent catch. + * + * Two layers. The mocked-`safeFetch` block pins what the transport asks for + * and what it says when that fails. The loopback block runs the real + * `safeFetch` against a real HTTP server on 127.0.0.1, which is the case the + * public-host pin used to refuse: an address that is not public at all. + */ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetLoggingConfig } from "../config"; +import type { WideEvent } from "../types"; + +const safeFetchMock = vi.hoisted(() => vi.fn()); +const useRealSafeFetch = vi.hoisted(() => ({ value: false })); + +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + safeFetch: (...args: Parameters) => + useRealSafeFetch.value + ? actual.safeFetch(...args) + : safeFetchMock(...args), + }; +}); + +import { SafeFetchError } from "@/lib/safe-fetch"; +import { + _resetLokiTransportForTests, + emitEvent, + flushLokiBuffer, + LOKI_FAILURE_NOTICE_INTERVAL_MS, + resolveLokiPushTarget, +} from "../transports"; + +const USERNAME = "loki-user"; +const PASSWORD = "s3cret-loki-password"; +const BASIC = Buffer.from(`${USERNAME}:${PASSWORD}`).toString("base64"); + +function makeEvent(): WideEvent { + return { + timestamp: new Date().toISOString(), + duration_ms: 1, + request_id: "req-loki", + trace_id: "trace-loki", + level: "info", + kind: "http", + service: "healthlog", + environment: "test", + }; +} + +let stderrLines: string[]; +let stderrSpy: ReturnType; +let stdoutSpy: ReturnType; + +function configure(endpoint: string, withAuth = true): void { + vi.stubEnv("LOKI_ENDPOINT", endpoint); + vi.stubEnv("LOKI_USERNAME", withAuth ? USERNAME : ""); + vi.stubEnv("LOKI_PASSWORD", withAuth ? PASSWORD : ""); + resetLoggingConfig(); +} + +function buffer(count: number): void { + for (let i = 0; i < count; i++) emitEvent(makeEvent()); +} + +beforeEach(() => { + _resetLokiTransportForTests(); + safeFetchMock.mockReset(); + useRealSafeFetch.value = false; + stderrLines = []; + stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + stderrLines.push(String(chunk)); + return true; + }); + stdoutSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true); +}); + +afterEach(() => { + stderrSpy.mockRestore(); + stdoutSpy.mockRestore(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + resetLoggingConfig(); + _resetLokiTransportForTests(); +}); + +describe("resolveLokiPushTarget", () => { + it.each([ + ["http://loki:3100", "http://loki:3100/loki/api/v1/push"], + ["http://loki:3100/", "http://loki:3100/loki/api/v1/push"], + ["http://loki:3100/loki/api/v1/push", "http://loki:3100/loki/api/v1/push"], + ["http://loki:3100/loki/api/v1/push/", "http://loki:3100/loki/api/v1/push"], + [ + "https://logs.example.com/tenant-a", + "https://logs.example.com/tenant-a/loki/api/v1/push", + ], + [ + "https://logs.example.com/tenant-a/loki/api/v1/push", + "https://logs.example.com/tenant-a/loki/api/v1/push", + ], + [" http://10.0.0.5:3100 ", "http://10.0.0.5:3100/loki/api/v1/push"], + ])("%s pushes to %s", (endpoint, expected) => { + const resolved = resolveLokiPushTarget(endpoint); + expect(resolved.ok).toBe(true); + if (resolved.ok) expect(resolved.target.url).toBe(expected); + }); + + it("approves exactly the endpoint origin, private addresses included", () => { + const resolved = resolveLokiPushTarget("http://192.168.1.20:3100/"); + expect(resolved).toEqual({ + ok: true, + target: { + url: "http://192.168.1.20:3100/loki/api/v1/push", + origin: "http://192.168.1.20:3100", + }, + }); + }); + + it.each([ + "http://169.254.169.254", + "http://169.254.10.1:3100", + "http://0.0.0.0:3100", + "http://[fe80::1]:3100", + ])("refuses the never-grantable host %s", (endpoint) => { + const resolved = resolveLokiPushTarget(endpoint); + expect(resolved.ok).toBe(false); + if (!resolved.ok) expect(resolved.reason).toBe("never_grantable"); + }); + + it.each([ + "loki:3100", + "ftp://loki:3100", + "http://user:pw@loki:3100", + "http://loki:3100?token=abc", + ])("refuses the malformed endpoint %s", (endpoint) => { + const resolved = resolveLokiPushTarget(endpoint); + expect(resolved.ok).toBe(false); + if (!resolved.ok) { + expect(resolved.reason).toBe("invalid_endpoint"); + expect(resolved.shown).not.toContain("pw"); + expect(resolved.shown).not.toContain("abc"); + } + }); +}); + +describe("flushLokiBuffer — dial path", () => { + it("dials a private endpoint as an operator-approved origin, not through the public pin", async () => { + configure("http://10.0.0.5:3100/loki/api/v1/push"); + safeFetchMock.mockResolvedValue(new Response(null, { status: 204 })); + buffer(2); + + await flushLokiBuffer(); + + expect(safeFetchMock).toHaveBeenCalledTimes(1); + const [url, init, opts] = safeFetchMock.mock.calls[0]; + expect(url).toBe("http://10.0.0.5:3100/loki/api/v1/push"); + expect(init.headers.Authorization).toBe(`Basic ${BASIC}`); + expect(opts.operatorApprovedPrivateOrigin).toBe("http://10.0.0.5:3100"); + expect(opts.requirePublicHost).toBeUndefined(); + expect(JSON.parse(init.body).streams[0].values).toHaveLength(2); + expect(stderrLines).toEqual([]); + }); + + it("never dials a metadata endpoint and says why", async () => { + configure("http://169.254.169.254:3100"); + buffer(3); + + await flushLokiBuffer(); + + expect(safeFetchMock).not.toHaveBeenCalled(); + expect(stderrLines).toHaveLength(1); + expect(stderrLines[0]).toContain("never_grantable"); + expect(stderrLines[0]).toContain("3 events dropped"); + }); +}); + +describe("flushLokiBuffer — failures reach stderr, once per window", () => { + it("reports a non-2xx answer with origin, status and dropped count", async () => { + configure("https://logs.example.com/loki/api/v1/push/loki/api/v1/push"); + safeFetchMock.mockResolvedValue( + new Response("404 page not found", { status: 404 }), + ); + buffer(4); + + await flushLokiBuffer(); + + expect(stderrLines).toHaveLength(1); + expect(stderrLines[0]).toContain("https://logs.example.com"); + expect(stderrLines[0]).toContain("HTTP 404"); + expect(stderrLines[0]).toContain("4 events dropped"); + }); + + it("reports a thrown error by class and socket code, never by message", async () => { + configure("http://loki.lan:3100"); + const cause = Object.assign(new Error("connect ECONNREFUSED"), { + code: "ECONNREFUSED", + }); + safeFetchMock.mockRejectedValue( + new SafeFetchError( + `safeFetch network error: http://${USERNAME}:${PASSWORD}@loki.lan`, + "network", + { cause }, + ), + ); + buffer(1); + + await flushLokiBuffer(); + + expect(stderrLines).toHaveLength(1); + expect(stderrLines[0]).toContain("http://loki.lan:3100"); + expect(stderrLines[0]).toContain("network ECONNREFUSED"); + expect(stderrLines[0]).toContain("1 event dropped"); + }); + + it("writes one line per reason per window and carries the suppressed count forward", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + configure("http://loki.lan:3100"); + safeFetchMock.mockImplementation( + async () => new Response(null, { status: 503 }), + ); + + for (let i = 0; i < 5; i++) { + buffer(2); + await flushLokiBuffer(); + vi.advanceTimersByTime(5_000); + } + expect(stderrLines).toHaveLength(1); + expect(stderrLines[0]).toContain("2 events dropped"); + + // A different reason is not muted by the first one's window. + safeFetchMock.mockImplementationOnce( + async () => new Response(null, { status: 401 }), + ); + buffer(1); + await flushLokiBuffer(); + expect(stderrLines).toHaveLength(2); + expect(stderrLines[1]).toContain("HTTP 401"); + + vi.advanceTimersByTime(LOKI_FAILURE_NOTICE_INTERVAL_MS); + buffer(2); + await flushLokiBuffer(); + expect(stderrLines).toHaveLength(3); + expect(stderrLines[2]).toContain("HTTP 503"); + // Four suppressed batches of two plus this batch of two. + expect(stderrLines[2]).toContain("10 events dropped"); + }); + + it("never prints the credentials or the basic-auth header", async () => { + configure("http://loki.lan:3100"); + safeFetchMock.mockResolvedValueOnce(new Response(null, { status: 403 })); + safeFetchMock.mockRejectedValueOnce( + new SafeFetchError(`failed with Basic ${BASIC} ${PASSWORD}`, "timeout"), + ); + buffer(1); + await flushLokiBuffer(); + buffer(1); + await flushLokiBuffer(); + + expect(stderrLines).toHaveLength(2); + const all = stderrLines.join(""); + expect(all).not.toContain(PASSWORD); + expect(all).not.toContain(USERNAME); + expect(all).not.toContain(BASIC); + }); + + it("stays silent on success", async () => { + configure("http://loki.lan:3100"); + safeFetchMock.mockResolvedValue(new Response(null, { status: 204 })); + buffer(1); + await flushLokiBuffer(); + expect(stderrLines).toEqual([]); + }); + + it("does not feed its own notice back into the Loki buffer", async () => { + configure("http://loki.lan:3100"); + safeFetchMock.mockResolvedValue(new Response(null, { status: 500 })); + buffer(1); + await flushLokiBuffer(); + expect(stderrLines).toHaveLength(1); + + // Nothing was buffered by the failure, so the next flush has nothing + // to send. + safeFetchMock.mockClear(); + await flushLokiBuffer(); + expect(safeFetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("flushLokiBuffer — real dial to a loopback Loki", () => { + let server: Server; + let received: Array<{ url: string; auth: string | undefined }>; + let status: number; + + beforeEach(async () => { + useRealSafeFetch.value = true; + received = []; + status = 204; + server = createServer((req, res) => { + received.push({ url: req.url ?? "", auth: req.headers.authorization }); + req.resume(); + req.on("end", () => { + res.statusCode = status; + res.end(); + }); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + function endpoint(path = ""): string { + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${port}${path}`; + } + + it("delivers to a non-public address given as the full push URL", async () => { + configure(endpoint("/loki/api/v1/push")); + buffer(1); + + await flushLokiBuffer(); + + expect(received).toEqual([ + { url: "/loki/api/v1/push", auth: `Basic ${BASIC}` }, + ]); + expect(stderrLines).toEqual([]); + }); + + it("reports the 404 a wrong path produces instead of swallowing it", async () => { + status = 404; + configure(endpoint("/wrong")); + buffer(2); + + await flushLokiBuffer(); + + expect(received).toHaveLength(1); + expect(received[0].url).toBe("/wrong/loki/api/v1/push"); + expect(stderrLines).toHaveLength(1); + expect(stderrLines[0]).toContain(`${endpoint()} failed (HTTP 404)`); + }); +}); diff --git a/src/lib/logging/transports.ts b/src/lib/logging/transports.ts index 4bbddb090..0e1b4cb02 100644 --- a/src/lib/logging/transports.ts +++ b/src/lib/logging/transports.ts @@ -2,7 +2,8 @@ import type { WideEvent } from "./types"; import { getLoggingConfig } from "./config"; import { shouldEmit } from "./sampler"; import { appendLogEvent } from "./in-memory-buffer"; -import { safeFetch } from "@/lib/safe-fetch"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; +import { canonicalOrigin } from "@/lib/private-origin-policy"; /** Event auf stdout als einzelne JSON-Zeile schreiben */ function emitToStdout(event: WideEvent): void { @@ -31,7 +32,133 @@ function initLokiTransport(): void { if (lokiFlushTimer.unref) lokiFlushTimer.unref(); } -async function flushLokiBuffer(): Promise { +const LOKI_PUSH_PATH = "/loki/api/v1/push"; +const LOKI_PUSH_TIMEOUT_MS = 10_000; + +export interface LokiPushTarget { + /** The URL the batch is POSTed to. */ + url: string; + /** + * The exact origin handed to `safeFetch` as the operator approval. Also the + * only part of the endpoint that ever appears in a failure line: it carries + * no userinfo, path or query by construction. + */ + origin: string; +} + +export type LokiTargetResolution = + | { ok: true; target: LokiPushTarget } + | { + ok: false; + reason: "invalid_endpoint" | "never_grantable"; + shown: string; + }; + +/** + * Turn `LOKI_ENDPOINT` into the push URL and the origin it may dial. + * + * Both spellings an operator reaches for work: the base URL + * (`http://loki:3100`, the push path is appended) and the full push URL + * (`http://loki:3100/loki/api/v1/push`, used as is, trailing slash or not). + * Appending unconditionally turned the second form into + * `/loki/api/v1/push/loki/api/v1/push` and a 404. + * + * Why the endpoint dials as an operator-approved origin rather than through + * the public-host pin: `LOKI_ENDPOINT` is read from the server environment in + * `config.ts` and nowhere else. No request field, settings row or admin form + * can set it, so there is no user-supplied host to defend against, and the + * public-only pin was refusing the common deployment (a Loki on the same LAN + * or Docker network) without a word. The exact origin still goes through the + * pinned operator resolver in `safeFetch` with redirects forbidden, and the + * never-grantable floor holds: an endpoint whose literal host is the + * unspecified address, link-local or the metadata range is refused here + * (`canonicalOrigin`), and a name that resolves there is dropped at dial time. + */ +export function resolveLokiPushTarget(endpoint: string): LokiTargetResolution { + let url: URL; + try { + url = new URL(endpoint.trim()); + } catch { + return { ok: false, reason: "invalid_endpoint", shown: "(unparseable)" }; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { ok: false, reason: "invalid_endpoint", shown: url.protocol }; + } + // `url.origin` never carries userinfo, so it is safe to print even when + // the endpoint itself is refused for carrying credentials. + if (url.username || url.password || url.search || url.hash) { + return { ok: false, reason: "invalid_endpoint", shown: url.origin }; + } + const origin = canonicalOrigin(url.origin); + if (!origin) { + return { ok: false, reason: "never_grantable", shown: url.origin }; + } + const basePath = url.pathname.replace(/\/+$/, ""); + const pushPath = basePath.endsWith(LOKI_PUSH_PATH) + ? basePath + : `${basePath}${LOKI_PUSH_PATH}`; + return { ok: true, target: { url: `${origin}${pushPath}`, origin } }; +} + +/** + * Failure notices for the Loki push, written straight to stderr. + * + * Never through `emitEvent`: a failing Loki push that reported itself as a + * wide event would buffer that event for the same failing push. At most one + * line per reason per window, so a Loki that is down for an hour costs twelve + * lines, not seven hundred. Events dropped while a reason is quiet are counted + * and reported on its next line, so the numbers add up. + */ +export const LOKI_FAILURE_NOTICE_INTERVAL_MS = 5 * 60 * 1000; +const lokiFailureNotices = new Map< + string, + { lastAt: number; droppedSinceLast: number } +>(); + +function reportLokiFailure( + shownEndpoint: string, + reason: string, + droppedEvents: number, +): void { + const now = Date.now(); + const entry = lokiFailureNotices.get(reason); + if (entry && now - entry.lastAt < LOKI_FAILURE_NOTICE_INTERVAL_MS) { + entry.droppedSinceLast += droppedEvents; + return; + } + const dropped = droppedEvents + (entry?.droppedSinceLast ?? 0); + lokiFailureNotices.set(reason, { lastAt: now, droppedSinceLast: 0 }); + process.stderr.write( + `[logging] Loki push to ${shownEndpoint} failed (${reason}); ` + + `${dropped} event${dropped === 1 ? "" : "s"} dropped. ` + + `Repeats of this failure are reported at most every 5 minutes.\n`, + ); +} + +/** + * The failure reason as a short class, never the error message: messages + * from the fetch stack can embed the target URL, and this line goes to stderr + * unredacted. A socket error code (`ECONNREFUSED`, `ENOTFOUND`) is kept + * because it is the part that tells an operator what to fix. + */ +function classifyLokiError(err: unknown): string { + const kind = err instanceof SafeFetchError ? err.kind : "error"; + let cause: unknown = err instanceof SafeFetchError ? err.cause : err; + for ( + let depth = 0; + depth < 4 && cause && typeof cause === "object"; + depth++ + ) { + const code = (cause as { code?: unknown }).code; + if (typeof code === "string" && /^[A-Z][A-Z0-9_]{1,40}$/.test(code)) { + return `${kind} ${code}`; + } + cause = (cause as { cause?: unknown }).cause; + } + return kind; +} + +export async function flushLokiBuffer(): Promise { if (lokiBuffer.length === 0) return; const config = getLoggingConfig(); if (!config.lokiEndpoint) return; @@ -39,6 +166,13 @@ async function flushLokiBuffer(): Promise { const batch = lokiBuffer; lokiBuffer = []; + const resolved = resolveLokiPushTarget(config.lokiEndpoint); + if (!resolved.ok) { + reportLokiFailure(resolved.shown, resolved.reason, batch.length); + return; + } + const { target } = resolved; + const streams = [ { stream: { @@ -64,21 +198,41 @@ async function flushLokiBuffer(): Promise { ); } + let res: Response; try { - await safeFetch( - `${config.lokiEndpoint}/loki/api/v1/push`, + res = await safeFetch( + target.url, { method: "POST", headers, body: JSON.stringify({ streams }), }, - // Operator-configured Loki endpoint — pin the connect-time IP - // against DNS rebinding. - { timeoutMs: 10_000, requirePublicHost: true }, + // Operator-configured endpoint: dial exactly its origin through the + // pinned operator resolver (see `resolveLokiPushTarget`). + { + timeoutMs: LOKI_PUSH_TIMEOUT_MS, + operatorApprovedPrivateOrigin: target.origin, + }, ); - } catch { - // Events gehen verloren — akzeptabel fuer Logging + } catch (err) { + // The batch is already detached from the buffer; say so rather than + // losing it without a trace. + reportLokiFailure(target.origin, classifyLokiError(err), batch.length); + return; } + + // Loki answers 204. Anything else, including a 3xx the forbidden redirect + // left unfollowed, means the batch did not land. + if (!res.ok) { + reportLokiFailure(target.origin, `HTTP ${res.status}`, batch.length); + } + await res.body?.cancel().catch(() => {}); +} + +/** Test helper: clear the buffer and the failure-notice windows. */ +export function _resetLokiTransportForTests(): void { + lokiBuffer = []; + lokiFailureNotices.clear(); } /** diff --git a/src/proxy.ts b/src/proxy.ts index 23c1e0cf7..7eea0ed35 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -244,6 +244,26 @@ const LEGACY_ADMIN_ANCHORS: Record = { "/admin/section-danger-zone": "/admin/danger-zone", }; +/** + * Presence marker on every HealthLog page response, read by `public/sw.js`. + * + * The service worker is registered with scope `/` and serves `/_next/static/*` + * cache-first. When HealthLog is stopped and a different app starts on the + * same origin (`localhost:3000` on a developer machine is the usual case), the + * worker went on serving HealthLog's cached chunks to that app (#847). It + * needs a way to tell a HealthLog page from someone else's, and nothing + * HealthLog already sends is specific enough: `x-request-id`, the nonce CSP + * and the COOP/COEP set are headers any Next.js app may emit. So the proxy + * adds one of its own. + * + * Presence only, value `1`: no version and no build id, because a version on + * every page is a free fingerprint for anyone scanning for an outdated + * instance and the worker does not need it. Pages only, not `/api/*`: the + * worker checks navigations and nothing else. Keep the name in lockstep with + * `HEALTHLOG_PAGE_MARKER_HEADER` in `public/sw.js`. + */ +export const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page"; + function generateNonce(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)); let binary = ""; @@ -484,6 +504,10 @@ export function proxy(request: NextRequest) { // Request ID for log correlation response.headers.set("x-request-id", requestId); + if (!isApiRoute) { + response.headers.set(HEALTHLOG_PAGE_MARKER_HEADER, "1"); + } + // The document vault's decrypt-and-serve route is framed SAME-ORIGIN by // the document detail preview (an