From 8581f4794331420f8a208a2a610a344565d54f2c Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sun, 30 Aug 2026 03:45:54 +0000 Subject: [PATCH 1/2] The gateway refused the script it was serving A release changed the document's inline theme bootstrap and left gateway.mjs byte identical. The cutover therefore did the right thing and left the gateway running, and the running gateway went on allowing the previous build's script hash while refusing the one it was itself serving: Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'sha256-TLx68/+2SeR3+dZreFBX1fiMo91olqe4aVTfKFd48Ik='' Every page loaded with the theme bootstrap blocked. The only sign was a console error, which is why this is the second defect this week whose whole visible symptom was something nobody was looking at. The cause is a contradiction inside this file. The static root is a fixed path whose contents a release swaps underneath it, and that is deliberate: it is exactly why a frontend change needs no gateway restart, and the release script says so in as many words. The policy was computed once at start-up and did not follow that swap. One half of the file tracked the build behind the path and the other half remembered the build that was there when the process began. It follows the file now, keyed on size and modification time, so a document costs one stat and a hash is computed only when the file actually changes. The test writes two builds behind the same path and asserts the policy names the second and not the first, including the case where both land in the same second, which is a window a release fits inside. Against the previous start-up-pinned version it fails. --- scripts/universe/gateway.mjs | 48 +++++++++++++++++++++++++++---- scripts/universe/gateway.test.mjs | 48 ++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/scripts/universe/gateway.mjs b/scripts/universe/gateway.mjs index bfcca6a5f3a..292735df32c 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,54 @@ 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. + * + * Keyed on the file's size and modification time, so a document costs one stat + * and a hash is computed only when the file behind the path actually changes. + */ +let policyCache = null; + +function indexIdentity() { + try { + const stats = statSync(join(ROOT, 'index.html')); + return `${stats.size}:${stats.mtimeMs}`; + } catch { + return 'absent'; + } +} + +export function contentSecurityPolicy() { + const key = indexIdentity(); + if (policyCache?.key === key) { + return policyCache.value; + } const hashes = inlineScriptHashes(); - return CONTENT_SECURITY_POLICY_PARTS.map((part) => + const value = CONTENT_SECURITY_POLICY_PARTS.map((part) => part.startsWith('script-src') && hashes.length ? `${part} ${hashes.join(' ')}` : part, ).join('; '); -})(); + policyCache = { key, value }; + return value; +} 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..c90312b3cc7 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,48 @@ 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 } = 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'); + + const second = 'window.__b=2;window.__c=3;'; + writeFileSync(join(root, 'index.html'), ``); + // Same second, different content: the identity has to be more than a + // timestamp at one second resolution, which a release can land inside. + const when = new Date(1780000000000); + utimesSync(join(root, 'index.html'), when, when); + 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'); +}); From ce96d28f2558d5e5d9080ad6a7ab1edacfaadaf6 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sun, 30 Aug 2026 03:49:30 +0000 Subject: [PATCH 2/2] Read the policy from the file, not from a key that can collide The first version of this fix cached the policy against the document's size and modification time. That key is wrong in a way that would have been very hard to find afterwards: two builds of the same length restored to the same instant share it, which is exactly what a tar extraction preserving mtimes can produce, and the resulting failure is a blocked script and nothing else. The test asserted that case and passed for the wrong reason. `utimesSync` takes a Date, so it wrote a millisecond-truncated time while `mtimeMs` carries finer precision, and the keys differed by an accident of rounding rather than by anything the code intended. The document is 3.4 kilobytes and is the same file the response is about to serve. The saving was never worth the class of bug it kept open, so it is read each time. The test now covers both dimensions on purpose: a build that changes the length and the time, and a build of exactly the same length with the modification time put back. Neither may be load bearing on its own. Against a start-up-pinned policy it still fails. --- scripts/universe/gateway.mjs | 28 ++++++++-------------------- scripts/universe/gateway.test.mjs | 25 ++++++++++++++++++------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/scripts/universe/gateway.mjs b/scripts/universe/gateway.mjs index 292735df32c..06a29c14f68 100644 --- a/scripts/universe/gateway.mjs +++ b/scripts/universe/gateway.mjs @@ -149,33 +149,21 @@ function inlineScriptHashes() { * while refusing the script it was itself serving. Every page loaded with the * theme bootstrap blocked, and the only sign was a console error. * - * Keyed on the file's size and modification time, so a document costs one stat - * and a hash is computed only when the file behind the path actually changes. + * 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. */ -let policyCache = null; - -function indexIdentity() { - try { - const stats = statSync(join(ROOT, 'index.html')); - return `${stats.size}:${stats.mtimeMs}`; - } catch { - return 'absent'; - } -} - export function contentSecurityPolicy() { - const key = indexIdentity(); - if (policyCache?.key === key) { - return policyCache.value; - } const hashes = inlineScriptHashes(); - const value = CONTENT_SECURITY_POLICY_PARTS.map((part) => + return CONTENT_SECURITY_POLICY_PARTS.map((part) => part.startsWith('script-src') && hashes.length ? `${part} ${hashes.join(' ')}` : part, ).join('; '); - policyCache = { key, value }; - return value; } function withSecurityHeaders(headers, isDocument) { diff --git a/scripts/universe/gateway.test.mjs b/scripts/universe/gateway.test.mjs index c90312b3cc7..b25023e119c 100644 --- a/scripts/universe/gateway.test.mjs +++ b/scripts/universe/gateway.test.mjs @@ -154,7 +154,7 @@ test('a handover of no sockets is not a handover', () => { * 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 } = await import('node:fs'); + 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'); @@ -174,15 +174,26 @@ test('the content policy follows the build behind the static root', async () => 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'), ``); - // Same second, different content: the identity has to be more than a - // timestamp at one second resolution, which a release can land inside. - const when = new Date(1780000000000); - utimesSync(join(root, 'index.html'), when, when); - 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', + ); });