diff --git a/scripts/generate-service-worker-assets.ts b/scripts/generate-service-worker-assets.ts index d89e9ac..164ea5c 100644 --- a/scripts/generate-service-worker-assets.ts +++ b/scripts/generate-service-worker-assets.ts @@ -1,6 +1,9 @@ import { createHash } from "node:crypto"; -import { canonicalStaticManifestBytes } from "../src/contracts/service-worker-static-manifest.ts"; +import { + CACHEABLE_ASSET_CONTENT_TYPES, + canonicalStaticManifestBytes, +} from "../src/contracts/service-worker-static-manifest.ts"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -20,15 +23,9 @@ import { const OUTPUT = ".generated/frontend-runtime/service-worker-assets.ts"; -const CACHEABLE_EXTENSIONS: Readonly> = Object.freeze({ - ".js": "text/javascript", - ".mjs": "text/javascript", - ".css": "text/css", - ".woff2": "font/woff2", - ".svg": "image/svg+xml", - ".png": "image/png", - ".webp": "image/webp", -}); +// SW-RR-03. The generator and the shared decoder read the same table, so a +// manifest this script produces can never be one the runtime contract refuses. +const CACHEABLE_EXTENSIONS = CACHEABLE_ASSET_CONTENT_TYPES; const EXCLUDED_FILES: ReadonlySet = new Set([ "index.html", diff --git a/src/adapters/service-worker/service-worker-lifecycle.ts b/src/adapters/service-worker/service-worker-lifecycle.ts index ffa8ad9..7a700af 100644 --- a/src/adapters/service-worker/service-worker-lifecycle.ts +++ b/src/adapters/service-worker/service-worker-lifecycle.ts @@ -224,13 +224,18 @@ export function createServiceWorkerRuntime( ? staticCacheName(config.manifest.setDigest) : null; if (!currentCacheName) return null; + // SW-RR-04. Both `open` and `match` are storage calls that can throw + // synchronously or reject. Either one escaping here rejects `respondWith` + // itself, so the entry never reaches its network fallback and the page + // gets a network error instead of the live response. + let cached: Response | undefined; let currentCache: Cache; try { currentCache = await scope.caches.open(currentCacheName); + cached = await currentCache.match(request.url); } catch { return null; } - const cached = await currentCache.match(request.url); if (!cached) return null; if (cached.status !== 200 || cached.type === "opaque") { // §18.6. An invalid hit is deleted from the cache it was read from and @@ -474,6 +479,69 @@ function isClientWithinRegistrationScope( } } +/** + * SW-RR-01. Reads at most one byte beyond the marker ceiling, cancels the + * reader as soon as that byte arrives, and fails closed on invalid UTF-8. The + * reader is also raced against a deadline so a stream that never produces a + * chunk cannot hold `activate` open. + */ +const ACTIVATION_MARKER_READ_DEADLINE_MS = 5_000; + +async function readBoundedMarkerText( + response: Response, +): Promise { + if (!response.body) { + try { + const text = await response.text(); + return new TextEncoder().encode(text).byteLength > + ACTIVATION_MARKER_MAX_BYTES + ? null + : text; + } catch { + return null; + } + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let timer: ReturnType | undefined; + const deadline = new Promise<"DEADLINE">((resolve) => { + timer = setTimeout( + () => resolve("DEADLINE"), + ACTIVATION_MARKER_READ_DEADLINE_MS, + ); + }); + try { + for (;;) { + const next = await Promise.race([reader.read(), deadline]); + if (next === "DEADLINE") return null; + if (next.done) break; + if (!next.value) continue; + total += next.value.byteLength; + if (total > ACTIVATION_MARKER_MAX_BYTES) return null; + chunks.push(next.value); + } + } catch { + return null; + } finally { + if (timer !== undefined) clearTimeout(timer); + // Never awaited: cancelling a stream whose source ignores cancellation can + // itself hang, and the marker read already has its answer. + void reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return null; + } +} + async function readActivationMarker( cache: Cache, expectedCacheName: string, @@ -489,10 +557,11 @@ async function readActivationMarker( ) { return null; } - const text = await response.text(); - if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) { - return null; - } + // SW-RR-01. A Content-Length is a claim, not a bound. Without one the + // previous `response.text()` read the whole body, so a large or + // non-terminating stream could consume the activation step indefinitely. + const text = await readBoundedMarkerText(response); + if (text === null) return null; const value: unknown = JSON.parse(text); if ( value === null || diff --git a/src/adapters/service-worker/service-worker-page-controller.ts b/src/adapters/service-worker/service-worker-page-controller.ts index f88e4c7..841ef1f 100644 --- a/src/adapters/service-worker/service-worker-page-controller.ts +++ b/src/adapters/service-worker/service-worker-page-controller.ts @@ -323,10 +323,11 @@ export function createServiceWorkerPageController( ) { return; } - // SW-06. The reply must come from the exact worker this request was - // sent to. A source swap ends the request immediately as a protocol - // mismatch rather than waiting for the drain timeout. - if (event.source !== null && event.source !== waiting) { + // SW-06 / SW-RR-02. The reply must come from the exact worker this + // request was sent to. A matching nonce is not identity: `null` + // source means the sender cannot be established, so it is refused + // like any other mismatch rather than accepted as this worker. + if (event.source !== waiting) { finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const })); return; } @@ -414,8 +415,10 @@ export function createServiceWorkerPageController( ) { return; } + // SW-RR-02. An unattributable reset result is never proof this + // controller performed the reset. if ( - (event.source !== null && event.source !== requestedController) || + event.source !== requestedController || container.controller !== requestedController ) { finish( diff --git a/src/adapters/web-push/inbound/notification-click-adapter.ts b/src/adapters/web-push/inbound/notification-click-adapter.ts index 4b1d004..490ca24 100644 --- a/src/adapters/web-push/inbound/notification-click-adapter.ts +++ b/src/adapters/web-push/inbound/notification-click-adapter.ts @@ -3,6 +3,7 @@ import { WEB_PUSH_PROTOCOLS, webPushFailure, webPushSuccess, + type WebPushNativeEffectCertainty, type WebPushObserver, type WebPushResult, } from "../../../contracts/web-push.ts"; @@ -44,6 +45,29 @@ export type NotificationClickAdapter = Readonly<{ handle(event: NotificationClickEventFacade): Promise>; }>; +/** + * WP-RR-01. Bounds a native effect by the handler lifetime while keeping the + * abandoned promise observable exactly once. + */ +const ABORT_OWNED = Symbol("web-push-click-aborted"); + +async function raceAbort( + operation: Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) return ABORT_OWNED; + let onAbort: (() => void) | undefined; + const aborted = new Promise((resolve) => { + onAbort = () => resolve(ABORT_OWNED); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([operation, aborted]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + export function createNotificationClickAdapter(dependencies: Readonly<{ fenceStore: PushAssociationFenceStore; clients: WorkerClientsFacade; @@ -156,23 +180,98 @@ export function createNotificationClickAdapter(dependencies: Readonly<{ expiresAt: decoded.value.expiresAt, path, }); + // WP-RR-01. `focus` and `openWindow` are user-visible native effects, so + // they carry the same certainty phase `showNotification` already does: + // NOT_APPLIED before the call, MAYBE_APPLIED while the promise is pending, + // CONFIRMED on fulfilment. An effect that lands after this handler's + // deadline is still observed exactly once — as evidence only, never as + // authorization to retry. + let nativeEffect: WebPushNativeEffectCertainty = "NOT_APPLIED"; + const observeLateEffect = ( + pending: Promise, + succeeded: (value: unknown) => boolean, + ): void => { + let observed = false; + void pending.then( + (value) => { + if (observed) return; + observed = true; + observeWebPush(dependencies.observer, { + event: "web_push_click_dispatched", + outcome: succeeded(value) ? "DEGRADED" : "FAILED", + reason: "ABORTED", + nativeEffect: succeeded(value) ? "CONFIRMED" : "NOT_APPLIED", + }); + }, + () => { + if (observed) return; + observed = true; + observeWebPush(dependencies.observer, { + event: "web_push_click_dispatched", + outcome: "FAILED", + reason: "ABORTED", + nativeEffect: "NOT_APPLIED", + }); + }, + ); + }; try { if (signal.aborted) { return webPushFailure("ABORTED", "NOTIFICATION_CLICK"); } if (existing) { existing.postMessage(handoff); - await existing.focus(); + const focused = Promise.resolve(existing.focus()); + nativeEffect = "MAYBE_APPLIED"; + const raced = await raceAbort(focused, signal); + if (raced === ABORT_OWNED) { + observeLateEffect(focused, () => true); + return webPushFailure("ABORTED", "NOTIFICATION_CLICK"); + } + nativeEffect = "CONFIRMED"; } else { - const opened = await dependencies.clients.openWindow(target); - if (!opened) return nativeFailure("NOTIFICATION_CLICK", true); + const opening = Promise.resolve( + dependencies.clients.openWindow(target), + ); + nativeEffect = "MAYBE_APPLIED"; + const raced = await raceAbort(opening, signal); + if (raced === ABORT_OWNED) { + observeLateEffect(opening, (value) => value !== null); + return webPushFailure("ABORTED", "NOTIFICATION_CLICK"); + } + if (!raced) { + nativeEffect = "NOT_APPLIED"; + observeWebPush(dependencies.observer, { + event: "web_push_click_dispatched", + outcome: "FAILED", + nativeEffect, + }); + return nativeFailure("NOTIFICATION_CLICK", true); + } + nativeEffect = "CONFIRMED"; } if (signal.aborted) { + observeWebPush(dependencies.observer, { + event: "web_push_click_dispatched", + outcome: "DEGRADED", + reason: "ABORTED", + nativeEffect, + }); return webPushFailure("ABORTED", "NOTIFICATION_CLICK"); } } catch { + observeWebPush(dependencies.observer, { + event: "web_push_click_dispatched", + outcome: "FAILED", + nativeEffect, + }); return nativeFailure("NOTIFICATION_CLICK", true); } + observeWebPush(dependencies.observer, { + event: "web_push_click_dispatched", + outcome: "SUCCEEDED", + nativeEffect, + }); return webPushSuccess(undefined); } diff --git a/src/contracts/service-worker-static-manifest.ts b/src/contracts/service-worker-static-manifest.ts index 96f4234..1f84e52 100644 --- a/src/contracts/service-worker-static-manifest.ts +++ b/src/contracts/service-worker-static-manifest.ts @@ -31,13 +31,23 @@ export type StaticAssetManifest = Readonly<{ export const STATIC_ASSET_SET_DOMAIN = "CA_STATIC_ASSET_SET_V1"; -/** Exact allowed extension → content type pairs for a cacheable asset. */ +/** + * SW-RR-03. The single authoritative extension → content type table. + * + * The build generator and this decoder must agree exactly: an extension the + * generator emits but the decoder refuses turns a correct build into a runtime + * contract failure, and the reverse admits an asset kind no build produces. + * `.json` is deliberately absent — every JSON file in a build output is a + * control document (runtime config, release manifest, schema), not a cacheable + * static asset, and the generator excludes them by name. + */ export const CACHEABLE_ASSET_CONTENT_TYPES: Readonly< Record > = Object.freeze({ ".css": "text/css", ".js": "text/javascript", - ".json": "application/json", + ".mjs": "text/javascript", + ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".woff2": "font/woff2", diff --git a/tests/unit/service-worker-web-push-remediation.test.ts b/tests/unit/service-worker-web-push-remediation.test.ts new file mode 100644 index 0000000..8e38e47 --- /dev/null +++ b/tests/unit/service-worker-web-push-remediation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CACHEABLE_ASSET_CONTENT_TYPES, + decodeStaticAssetManifest, +} from "../../src/contracts/service-worker-static-manifest.ts"; + +/** + * SW-RR-03. The build generator and the shared runtime decoder must agree on + * exactly which asset kinds exist. A generator that emits `.mjs` or `.png` while + * the decoder refuses them turns a correct build into a runtime contract + * failure, and the reverse admits a kind no build produces. + */ +describe("SW-RR-03 one authoritative cacheable asset table", () => { + it("covers every extension the generator emits", async () => { + const generator = await import( + "../../scripts/generate-service-worker-assets.ts" + ); + expect(generator).toBeDefined(); + for (const extension of [ + ".js", + ".mjs", + ".css", + ".woff2", + ".svg", + ".png", + ".webp", + ]) { + expect(CACHEABLE_ASSET_CONTENT_TYPES[extension]).toBeDefined(); + } + }); + + it("decodes a manifest row for every table entry", () => { + const assets = Object.entries(CACHEABLE_ASSET_CONTENT_TYPES).map( + ([extension, contentType], index) => ({ + url: `/assets/name-abcdefgh${index}${extension}`, + sha256: `sha256:${"a".repeat(64)}`, + bytes: 16, + contentType, + }), + ); + const decoded = decodeStaticAssetManifest({ + schemaVersion: 1, + buildId: "build-1", + releaseId: "release-1", + assets, + setDigest: `sha256:${"b".repeat(64)}`, + }); + expect(decoded).toMatchObject({ ok: true }); + }); + + it("refuses a row whose extension is not in the table", () => { + const decoded = decodeStaticAssetManifest({ + schemaVersion: 1, + buildId: "build-1", + releaseId: "release-1", + setDigest: `sha256:${"b".repeat(64)}`, + assets: [ + { + url: "/assets/control-abcdefgh.json", + sha256: `sha256:${"a".repeat(64)}`, + bytes: 16, + contentType: "application/json", + }, + ], + }); + expect(decoded.ok).toBe(false); + }); +});