Skip to content

The CSP can never be enforced as built: /rekor's inline script is written after the hashes are computed, and report-only has no report endpoint #269

Description

@bdelanghe

Cold read of https://bounded.tools, 2026-09-03, against the live deploy (commit 3a4ac1d).

Two defects that hide each other

scripts/emit-artifacts.mjs:92 explains the report-only choice:

shipped REPORT-ONLY first: a wrong directive only REPORTS to the console, never blocks, so it can't break the page; flip to enforcing once verified clean in a browser.

That plan needs reports. The emitted policy has ten directives and none of them is report-uri or report-to:

default-src 'self'; script-src 'self' 'sha256-+z3guWA9…'; style-src 'self' 'unsafe-inline';
img-src 'self'; font-src 'self'; connect-src 'self' https://api.github.com https://rekor.sigstore.dev;
base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'

A report-only policy with nowhere to report is inert. It does not block, and it tells no one. The header has been shipping in the one mode whose entire purpose is telemetry, with the telemetry off.

And it was hiding a real violation.

Measured

I served the live pages with that exact policy as an enforcing Content-Security-Policy and loaded each in Chromium:

Page Result
/, /map, /contracts, /conformance, /ledger, /blog/, /blog/provenance-is-not-legitimacy, /404 clean
/rekor Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'sha256-+z3guWA9…'"

Root cause

script-src carries exactly one hash, and by construction it can never carry /rekor's.

emit-artifacts.mjs walks dist/ and hashes every inline script it finds. deploy.yml runs the stamped pipeline (line 87), which includes emit-artifacts.mjs, and only then runs gen-provenance.mjs (line 93). That script writes dist/rekor/index.html with an inline redirect (vendor/conformance-kit/integrity/gen-provenance.mjs:125):

<script>location.replace(${JSON.stringify(rekorUrl)})</script>

The ordering is deliberate and correct on its own terms: the page cannot exist before signing, because it carries the log index the signature produces. But it means the CSP hash set is computed over a dist/ that does not yet contain the last page written into it.

User-visible impact today

Small, and I want to be accurate about it. /rekor also carries <meta http-equiv="refresh">, which script-src does not govern, so the redirect still happens under enforcement. The script only makes it faster. Nothing is broken for a reader right now.

What is broken is the mechanism. The page that would have failed is the one-click path to the transparency log, which "Trust lives outside the page" points at as the thing you do not have to trust the page for. Nobody learned this in the weeks the header has been shipping, because the mode that would have told them had no endpoint.

Suggested shape

  1. Hash /rekor at the point it is written. Either have gen-provenance.mjs recompute the _headers CSP after it writes the sidecar, or drop the inline script and rely on the meta refresh alone, which needs no hash and no exception.
  2. Add a build check that every inline <script> in the final dist/ has a matching hash in the emitted CSP, so an ordering change cannot silently reintroduce this.
  3. Then flip to enforcing. On the evidence above, every other page is already clean under the exact policy.
  4. If report-only is kept for a transition window, give it a report-to endpoint. Otherwise the mode is decorative.

Filed from a cold read, unclaimed and unassigned.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions