mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
cd44a85537
commit
e34c5db45e
2 files changed
+276
-28
No files matched your search
+86
-5
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user