diff --git a/server/player/sw.js b/server/player/sw.js index c369ae4..8e6b04f 100644 --- a/server/player/sw.js +++ b/server/player/sw.js @@ -54,11 +54,26 @@ self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Widget renders pinned to a revision: cache-FIRST, because those exact bytes cannot change - // without the rev changing. This is what lets a widget keep rendering when the network is gone — - // previously the server sent no-store for every render, so widgets were the one thing the - // player's offline cache could never hold, and a display that lost its uplink lost them. - // ignoreSearch is deliberately NOT used here: the query string carries the rev, and ignoring it - // would match a different revision's entry, which is the staleness we are trying to remove. + // without the rev changing. ignoreSearch is deliberately NOT used here: the query string carries + // the rev, and ignoring it would match a different revision's entry, which is the staleness we + // are trying to remove. + // + // MEASURED LIMIT, do not read more into this branch than it delivers. The player mounts widgets in + // an iframe sandboxed to `allow-scripts` with NO allow-same-origin (index.html, + // renderWidgetBuffered), so that frame is an OPAQUE-origin client — and a service worker does not + // control opaque-origin clients. Its navigation request never reaches this handler. Driven in + // Chrome against a live server: a clock widget mounted five times over 25s and the shell cache + // held zero widget entries, while a plain fetch() of the identical URL from the controlled page + // was intercepted and stored. So this branch serves anything that reaches it — a same-origin + // fetch, a future non-sandboxed mount — and today the player's own widgets are not that. + // + // What actually keeps widgets rendering offline right now is the HTTP cache plus the server's + // `max-age=31536000, immutable` on a rev-pinned render (routes/widgets.js). That is sound in a + // desktop browser and is NOT a documented-persistent store on BrightSign, which guarantees + // survival across reboots for IndexedDB, localStorage and SQLite only — the same gap that made + // content caching necessary. Closing it properly means routing the render through a same-origin + // fetch and mounting it as srcdoc; granting the frame allow-same-origin instead would hand widget + // scripts the player's origin, which is not a trade worth making for an offline nicety. if (url.pathname.startsWith('/api/widgets/') && url.pathname.endsWith('/render') && url.searchParams.has('rev')) { event.respondWith( caches.match(event.request).then(cached => { diff --git a/server/test/player-widget-frame-origin.test.js b/server/test/player-widget-frame-origin.test.js new file mode 100644 index 0000000..fd71dac --- /dev/null +++ b/server/test/player-widget-frame-origin.test.js @@ -0,0 +1,56 @@ +'use strict'; + +// The widget iframe's origin, and what it costs. +// +// The player mounts a widget in an iframe sandboxed to `allow-scripts` with NO allow-same-origin, +// which gives it an OPAQUE origin: widget scripts cannot read the player's window, its localStorage, +// or its device token. That is deliberate and worth keeping — widget HTML is operator-authored and +//, for the webpage widget, third-party. +// +// It has a consequence that is easy to forget and was in fact forgotten: a service worker does not +// control opaque-origin clients, so the widget frame's navigation NEVER reaches sw.js. The +// cache-first widget branch there is real code that this player's own widgets do not use — measured +// in Chrome, a clock widget mounted five times over 25 seconds while the shell cache held zero +// widget entries, and a plain fetch() of the same URL from the controlled page was cached +// immediately. Widgets survive an outage today on the HTTP cache and the server's immutable +// Cache-Control, not on the worker. +// +// So this test pins the security property, and pins the fact that the offline story for widgets +// rests on the HTTP header. Someone who "fixes" offline widgets by adding allow-same-origin trades +// the isolation for a cache — the wrong direction, and the reason this is asserted rather than left +// as a comment. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8'); +const SW = fs.readFileSync(path.join(__dirname, '..', 'player', 'sw.js'), 'utf8'); +const WIDGETS = fs.readFileSync(path.join(__dirname, '..', 'routes', 'widgets.js'), 'utf8'); + +test('the widget iframe is sandboxed into an opaque origin', () => { + const sandboxes = [...HTML.matchAll(/setAttribute\('sandbox',\s*'([^']*)'\)/g)].map((m) => m[1]); + assert.ok(sandboxes.length > 0, 'the player must sandbox its widget frames'); + for (const s of sandboxes) { + assert.match(s, /allow-scripts/, 'a widget needs scripts to be a widget'); + assert.doesNotMatch(s, /allow-same-origin/, + 'allow-same-origin would give widget scripts the player origin — its storage, its device token'); + } +}); + +test('the offline guarantee for widgets is the HTTP header, and the server still sets it', () => { + // If this regresses to no-store, widgets stop surviving an outage everywhere — and the service + // worker will NOT quietly cover for it, because it never sees the request. + assert.match(WIDGETS, /max-age=31536000, immutable/, + 'a rev-pinned render must stay hard-cacheable: it is the only thing holding widgets offline'); + assert.match(WIDGETS, /no-store/, 'a render with no rev must stay uncacheable — nothing distinguishes one from the next'); +}); + +test('the worker does not claim to be what keeps widgets offline', () => { + // The comment above that branch used to say it was. A worker cannot control an opaque-origin + // client, so the claim was false in exactly the deployment it was written for. + const branch = SW.slice(0, SW.indexOf("url.pathname.startsWith('/api/widgets/')")); + assert.match(branch, /opaque-origin|opaque origin/i, + 'sw.js must record that the widget frame is opaque-origin and bypasses it'); +});