diff --git a/public/sw.js b/public/sw.js index f2860edd0..67c604453 100644 --- a/public/sw.js +++ b/public/sw.js @@ -66,10 +66,16 @@ const CURRENT_HEALTHLOG_CACHES = new Set([ // 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 once a navigation proves the origin no longer serves HealthLog. From -// then on the fetch handler declines every request for the rest of this -// worker's life, so the other app's page and its assets come from the network. +// 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; @@ -370,13 +376,86 @@ function isCacheableNavigation(request, response) { * fallback in `networkFirst`). * * What is left is a successful page without the marker HealthLog's proxy puts - * on every page response. + * 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 @@ -420,8 +499,10 @@ async function networkFirst(event, cacheName) { (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(retireFromForeignOrigin()); + event.waitUntil(checkForeignOrigin()); return response; } if (response.ok && isCacheableNavigation(request, response)) { diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 82fc300e0..0ec33c658 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -673,6 +673,37 @@ function dispatchAnyFetch( 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( @@ -705,45 +736,180 @@ describe("sw.js — foreign origin (#847)", () => { expect(await (await asset.response!).text()).toBe("healthlog chunk"); }); - it("steps aside when a successful page arrives without the marker", async () => { + 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 = new Response("another app", { - headers: { "Content-Type": "text/html" }, - }); - harness.context.fetch = async () => foreignPage; + 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); - // The other app's page is returned untouched and never cached. - expect(res).toBe(foreignPage); + 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", ]); - // The cached HealthLog chunk would have been served cache-first before. - // Now the worker declines, so the browser fetches the other app's asset. + // 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(false); - const nextPage = dispatchAnyFetch(harness, "/about", { - accept: "text/html", - }); - expect(nextPage.responded).toBe(false); - const api = dispatchAnyFetch(harness, "/api/measurements"); - expect(api.responded).toBe(false); + 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); - let fetchCalls = 0; - harness.context.fetch = async () => { - fetchCalls += 1; - return healthlogPage("unexpected"); - }; + const calls = routeFetch( + harness, + () => healthlogPage("unexpected"), + () => new Response("Not Found", { status: 404 }), + ); const res = await dispatchNavigationFetch( harness, @@ -753,7 +919,8 @@ describe("sw.js — foreign origin (#847)", () => { await Promise.all(harness.lifetimePromises); expect(await res.text()).toBe("another app"); - expect(fetchCalls).toBe(0); + // The page came from the preload; the only network call is the probe. + expect(calls).toEqual(["/api/version"]); expect(harness.unregisterCalls.count).toBe(1); }); @@ -762,7 +929,7 @@ describe("sw.js — foreign origin (#847)", () => { harness.cacheStorage.keys = async () => { throw new Error("CacheStorage unavailable"); }; - harness.context.fetch = async () => new Response("another app"); + routeFetch(harness, foreignHtml, foreignHtml); await dispatchNavigationFetch(harness, "/"); await Promise.all(harness.lifetimePromises);