fix(pwa): confirm a foreign origin before the worker retires

A reverse proxy with a response-header allowlist strips
X-HealthLog-Page from every real HealthLog page, and the worker then
unregistered on each load and dropped Web Push subscriptions.

A successful page without the marker now only sends requests to the
network while the worker asks /api/version with no-store and a manual
redirect. It deletes its caches and unregisters only when the answer
positively is not HealthLog: a 404, a non-JSON 2xx, or JSON without
the version shape. A failed probe, any other status or the HealthLog
shape resumes normal service. buildSha may be null on a self-built
image, so only its presence is checked.

Refs #847
This commit is contained in:
Marc-André Bombeck committed 2026-09-13 14:16:52 +02:00
1 parent cd44a85537
commit e34c5db45e
2 files changed
+276 -28

No files matched your search

+86 -5
View File
@@ -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)) {
+190 -23
View File
@@ -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<Response>,
): 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("<html>another app</html>", {
headers: { "Content-Type": "text/html" },
});
async function seedHealthlogCaches(harness: SwHarness): Promise<void> {
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("<html>another app</html>", {
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<Response>);
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);