diff --git a/scripts/universe/gateway.mjs b/scripts/universe/gateway.mjs index bfcca6a5f3a..06a29c14f68 100644 --- a/scripts/universe/gateway.mjs +++ b/scripts/universe/gateway.mjs @@ -111,8 +111,8 @@ const CONTENT_SECURITY_POLICY_PARTS = [ /** * The build injects one inline script into the document to name the theme * files, and its content changes with every build. Rather than weaken the - * policy with 'unsafe-inline', its hash is computed once at start-up and - * allowed by name. Anything else inline stays blocked. + * policy with 'unsafe-inline', its hash is allowed by name. Anything else + * inline stays blocked. */ function inlineScriptHashes() { const index = join(ROOT, 'index.html'); @@ -133,18 +133,42 @@ function inlineScriptHashes() { return hashes; } -const CONTENT_SECURITY_POLICY = (() => { +/** + * The policy for the document being served now, not for the one that was there + * at start-up. + * + * This used to be computed once when the process began. That is wrong here for + * the same reason the static root is resolved per request: `ROOT` is a fixed + * path whose contents a release swaps underneath it, which is exactly why a + * frontend change needs no gateway restart. A policy pinned at start-up does + * not follow that swap. + * + * It reached production. A release changed the document's inline script and + * left `gateway.mjs` byte identical, so the cutover correctly left the gateway + * running, and the running gateway went on allowing the previous build's hash + * while refusing the script it was itself serving. Every page loaded with the + * theme bootstrap blocked, and the only sign was a console error. + * + * Read from the file on each document rather than cached against its size and + * modification time. That cheaper key is wrong in a way that is hard to see + * afterwards: two builds of the same length restored to the same instant, which + * is what a tar extraction preserving mtimes can produce, share it, and the + * failure it causes is a blocked script and nothing else. The document is 3.4 + * kilobytes and this is the same file the response is about to serve, so the + * saving was never worth the class of bug it kept open. + */ +export function contentSecurityPolicy() { const hashes = inlineScriptHashes(); return CONTENT_SECURITY_POLICY_PARTS.map((part) => part.startsWith('script-src') && hashes.length ? `${part} ${hashes.join(' ')}` : part, ).join('; '); -})(); +} function withSecurityHeaders(headers, isDocument) { const merged = { ...headers, ...SECURITY_HEADERS }; - if (isDocument) merged['content-security-policy'] = CONTENT_SECURITY_POLICY; + if (isDocument) merged['content-security-policy'] = contentSecurityPolicy(); return merged; } diff --git a/scripts/universe/gateway.test.mjs b/scripts/universe/gateway.test.mjs index d093c972005..b25023e119c 100644 --- a/scripts/universe/gateway.test.mjs +++ b/scripts/universe/gateway.test.mjs @@ -3,7 +3,8 @@ import assert from 'node:assert/strict'; // Importing the gateway must not open a socket. process.env.UNIVERSE_GATEWAY_NO_LISTEN = '1'; -const { routeFor, websocketUpstreamFor, inheritedListenerFd } = await import('./gateway.mjs'); +const { routeFor, websocketUpstreamFor, inheritedListenerFd, contentSecurityPolicy } = + await import('./gateway.mjs'); /** * The path rewrite is load bearing. The explorer backend registers every route @@ -140,3 +141,59 @@ test('a handover of no sockets is not a handover', () => { assert.equal(inheritedListenerFd({ LISTEN_PID: '42' }, 42), null); assert.equal(inheritedListenerFd({ LISTEN_PID: '42', LISTEN_FDS: 'two' }, 42), null); }); + +/** + * The document policy has to describe the document being served. + * + * `UNIVERSE_GATEWAY_ROOT` is a fixed path whose contents a release swaps + * underneath it, which is why a frontend change needs no gateway restart. The + * policy was computed once at start-up and did not follow that swap, so a + * release that changed the document's inline script and left `gateway.mjs` + * byte identical produced a running gateway allowing the previous build's hash + * and refusing the script it was itself serving. It reached production, and + * the only sign was a console error on every page. + */ +test('the content policy follows the build behind the static root', async () => { + const { mkdtempSync, writeFileSync, utimesSync, statSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { createHash } = await import('node:crypto'); + + const root = mkdtempSync(join(tmpdir(), 'gateway-csp-')); + process.env.UNIVERSE_GATEWAY_ROOT = root; + process.env.UNIVERSE_GATEWAY_NO_LISTEN = '1'; + // A second copy of the module, bound to a root this test controls. The one + // imported at the top of this file is bound to the default root. + const gateway = await import(`./gateway.mjs?csp=${Date.now()}`); + + const hashOf = (body) => + `'sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}'`; + + const first = 'window.__a=1;'; + writeFileSync(join(root, 'index.html'), ``); + const before = gateway.contentSecurityPolicy(); + assert.ok(before.includes(hashOf(first)), 'the first build is allowed by name'); + + // A longer script: the file changes size as well as time. + const second = 'window.__b=2;window.__c=3;'; + writeFileSync(join(root, 'index.html'), ``); + const after = gateway.contentSecurityPolicy(); + assert.ok(after.includes(hashOf(second)), 'the build now behind the path is allowed'); + assert.ok(!after.includes(hashOf(first)), 'the build that is gone is no longer allowed'); + + // And a script of exactly the same length, with the modification time forced + // back to what it was. Neither dimension of the cache key may be load + // bearing on its own: a build that changes the file without changing its + // size has to be noticed, and so has one that lands at the same instant. + const stamped = statSync(join(root, 'index.html')); + const third = 'window.__b=9;window.__c=8;'; + assert.equal(third.length, second.length, 'the two scripts are the same length'); + writeFileSync(join(root, 'index.html'), ``); + utimesSync(join(root, 'index.html'), stamped.atime, stamped.mtime); + + const sameSizeSameTime = gateway.contentSecurityPolicy(); + assert.ok( + sameSizeSameTime.includes(hashOf(third)), + 'a build of the same size at the same instant is still the build being served', + ); +});