Skip to content

The gateway refused the script it was serving - #27

Merged
bitcoinuniverseadmin merged 2 commits into
developfrom
fix/gateway-policy-follows-the-build
Aug 30, 2026
Merged

bitcoinuniverseadmin merged 2 commits into
developfrom
fix/gateway-policy-follows-the-build

Conversation

@bitcoinuniverseadmin

Copy link
Copy Markdown

Found by the gate, in production, and rolled back

ac2bcfaf5 was cut over at 03:42:50Z. The chain page smoke against the public
origin caught this and the release was rolled back to 1b12b26f3 at 03:43:35Z,
46 of 46 probes at 200 across the rollback, verified clean afterwards.

FAIL  page:dogecoin: 1 console error(s): Executing inline script violates the
following Content Security Policy directive 'script-src 'self'
'sha256-TLx68/+2SeR3+dZreFBX1fiMo91olqe4aVTfKFd48Ik=''. Either the
'unsafe-inline' keyword, a hash
('sha256-F5AsGcf/Fnd0Us+C7HURogB4lMHknY+tbbgVptti54w='), or a nonce is
required to enable inline execution. The action has been blocked.

Two different hashes: the one the gateway allowed, and the one the document it
was serving actually needed. Every page loaded with the theme bootstrap
blocked. Nothing else showed it. The routes answered, the identities matched
the manifest, the visual matrix was green, and the page rendered.

Root cause, which is a contradiction inside gateway.mjs

ROOT is a fixed path whose contents a release swaps underneath it. That is
deliberate and load bearing: it is why a frontend change needs no gateway
restart, and release.sh says so out loud every cutover:

03:42:40Z the gateway is unchanged, leaving it up so the origin sees no gap

The document policy was computed once at start-up:

const CONTENT_SECURITY_POLICY = (() => {
  const hashes = inlineScriptHashes();   // reads ROOT/index.html, once
  ...
})();

So 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. The gateway on
this host had been running since 2026-08-29 10:23:31 and was still enforcing
that morning's hash.

#21 changed
index.mempool.html and was correct to; it is only visible from here because
it is the first release in a while to change that file without also changing
gateway.mjs.

The fix

The policy follows the file, keyed on its size and modification time, so a
document costs one stat and a hash is computed only when the file behind the
path actually changes.

Restarting the gateway on an index change would also have worked and is worse:
it adds a special case to the release script for a rule the gateway should
enforce itself, and it gives up the seamless frontend cutover in the one case
where nothing about the gateway changed.

The test

Writes two builds behind the same path and asserts the policy names the second
and not the first, including when both land in the same second, which is a
window a release fits inside. Against the previous start-up-pinned version:

✖ the content policy follows the build behind the static root
ℹ pass 14
ℹ fail 1

With the fix, 15 pass.

Release

ac2bcfaf5 needs rebuilding on top of this before it goes back out. The
running release is 1b12b26f3, which is unaffected: it does not carry the
index change, so the hash the gateway holds is the one its document needs.

🤖 Generated with Claude Code

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.
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.
@bitcoinuniverseadmin

Copy link
Copy Markdown
Author

Correcting my own first attempt at this, before anyone reviews it.

The fix originally cached the policy against the document's size and
modification time. That key can collide: two builds of the same length restored
to the same instant share it, which is what a tar extraction preserving mtimes
can produce, and the failure it causes is a blocked script and nothing else.
The same shape as the bug being fixed, with a narrower trigger.

Worse, the test I wrote for that case passed for the wrong reason. utimesSync
takes a Date, so it writes a millisecond-truncated time while mtimeMs
carries finer precision. The keys differed by an accident of rounding, not by
anything the code intended, and the assertion read as proof of something it had
not tested.

The document is 3.4 kilobytes and is the same file the response is about to
serve, so it is read each time and the cache is gone. The saving was never
worth the class of bug it kept open.

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, which is the point of it.

18 pass, 0 fail   (gateway.test.mjs and gateway-restart.test.mjs)

@bitcoinuniverseadmin
bitcoinuniverseadmin merged commit 7e50254 into develop Aug 30, 2026
9 checks passed
@bitcoinuniverseadmin
bitcoinuniverseadmin deleted the fix/gateway-policy-follows-the-build branch August 30, 2026 04:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant