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',
+ );
+});