Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions scripts/universe/gateway.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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;
}

Expand Down
59 changes: 58 additions & 1 deletion scripts/universe/gateway.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'), `<html><script>${first}</script></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'), `<html><script>${second}</script></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'), `<html><script>${third}</script></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',
);
});
Loading