Automations and webhooks for the AT Protocol airglow.run

chore(security): confirm no CSP errors remain behind Cloudflare and narrow the beacon allowlist #69

Closed tyb.there-hugo.dev
Closed opened by tyb.there-hugo.dev

Optional, low priority.

Problem #

c88b677 (#11) allowlists the Cloudflare beacon, but nobody has checked it in production. The CSP also allows https://cloudflareinsights.com in connect-src, which automatic injection does not use.

Evidence #

  • lib/security-headers.ts:9-10,36 (c88b677).
  • Cloudflare Web Analytics FAQ: automatic injection connects to the same domain, and cloudflareinsights.com is for the manual snippet only.
  • In #11, both contributor and reviewer said "not checked in a browser behind Cloudflare".

Expected #

No CSP violations in the production console on the pages from the session video, and a CSP that grants only what the beacon needs.

Acceptance criteria #

Out of scope #

Turning off Cloudflare's injection; other CSP directives.

Queued: the loop will work on this version of the issue.

loop:queued cid=bafyreie5hamgg3jkfw4fp6a3im4fxuojv7ilihm4q3rctotjmws4l7h63a

Working on this, attempt 1.

loop:claim run=contribute-20261102-181826-69 branch=loop/69 attempt=1 issue=69

chore(security): drop the unused cloudflareinsights.com connect-src and explain the beacon host

The CSP no longer allows https://cloudflareinsights.com in connect-src: with Cloudflare's automatic injection the Web Analytics beacon reports to the same origin (/cdn-cgi/rum), which 'self' already covers, and only the manual snippet reports to cloudflareinsights.com. script-src keeps the https://static.cloudflareinsights.com host, and a comment now says why: automatically injected beacons load a versioned path (/beacon.min.js/v<hash>) that an exact /beacon.min.js source would block.

What I verified #

  • pnpm run check and pnpm run test pass (2839 tests). The test in lib/security-headers.test.ts now asserts that connect-src is exactly connect-src 'self' and still asserts that script-src allows https://static.cloudflareinsights.com without 'unsafe-inline'.
  • I fetched production (https://airglow.run/ plus two 404 pages) with Node's fetch and a browser user agent. No response had the beacon injected: there was no cloudflareinsights or cf-beacon anywhere in the HTML. POST https://airglow.run/cdn-cgi/rum returned Cloudflare's own 404. So right now production sends nothing to either endpoint, and I could not watch where a live beacon reports. Web Analytics may be off for the zone, or the edge may skip injection for these responses. The container blocks *.cloudflareinsights.com, so I could not fetch the beacon script either.
  • Because nothing shows beacons going to /cdn-cgi/rum, removing the connect-src host rests on Cloudflare's FAQ (cited in the issue) and the issue's own statement, not on watching production. The owner check below is what confirms it.

Acceptance criteria #

Decisions the owner can reverse #

  • Removing the connect-src host before seeing production evidence. If the owner check shows reports going to https://cloudflareinsights.com/cdn-cgi/rum, put back CLOUDFLARE_BEACON_CONNECT_ORIGIN (one line in lib/security-headers.ts and one assertion in the test).
  • Keeping the whole static.cloudflareinsights.com host instead of a path. If the injected script turns out to be the bare /beacon.min.js, or you'd rather allow just the versioned prefix, use https://static.cloudflareinsights.com/beacon.min.js together with https://static.cloudflareinsights.com/beacon.min.js/ (the trailing slash matches as a prefix).

Follow-up, filed as a new issue on merge if the review agrees:

fix(security): stop serving one CSP nonce to every visitor of edge-cached pages

Anonymous pages cached at the edge (setPageCache, e.g. / with public, s-maxage=300, stale-while-revalidate=3600, /automations and /sync) carry the per-request CSP nonce from lib/security-headers.ts in both the header and the inline <script nonce> tags, so Cloudflare hands the same nonce to every visitor until the entry expires. A nonce anyone can read off a public response no longer stops an injected inline script on that page, which is the protection script-src 'nonce-...' is there for.

How I know: on 2026-11-02 I fetched https://airglow.run/ three times with Node's fetch over ~20 s; every response had cf-cache-status: EXPIRED/HIT and the same script-src 'self' 'nonce-9XlfwwpW/oKlOLeOyTtIvQ==', and the body's inline scripts used that nonce. Uncached pages (404s, cf-cache-status: DYNAMIC) got a fresh nonce each time.

Where to start: setPageCache in lib/http.ts and the nonce in lib/security-headers.ts. Options: hashes ('sha256-...') for the fixed inline scripts (theme bootstrap, speculation rules) instead of a nonce on cacheable pages, moving those scripts to static files, or not caching pages that carry a nonce.

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/69

loop:pushed run=contribute-20261102-181826-69 sha=dd7c5225dbbedeac321eca52631320ee9c5f2159

Merge. The branch drops the unused https://cloudflareinsights.com from connect-src and adds a comment saying why script-src keeps the whole static.cloudflareinsights.com host. Both match what the issue asks, and the test in lib/security-headers.test.ts now pins connect-src to exactly 'self'. pnpm run check and pnpm run test (2839 tests) pass.

What I checked:

  • Criterion 2: the comment's reason holds. Automatically injected beacons load from /beacon.min.js/v<hash>. In a CSP source, a path with no trailing slash only matches that exact path, so https://static.cloudflareinsights.com/beacon.min.js would block the injected script. The issue accepts "a comment explains why the host is kept".
  • Criterion 3: the host is removed and the test is updated. Production evidence was not available: the contributor found no beacon injected on airglow.run, so nothing could be watched there. The removal therefore rests on the Cloudflare FAQ the owner cites in the issue, and on the owner's own statement that automatic injection does not use this host. Criterion 1 is already the owner's post-deploy console check, and it covers this. If it turns out wrong, the only cost is lost analytics reports, and putting the host back is a one-line change.
  • Bug class: I grepped the whole repo for cloudflareinsights, cdn-cgi, connect-src and Content-Security-Policy. The only other CSP is default-src 'none' on app/routes/api/favicon/[domain].ts, and it needs no change. No docs mention the old allowlist. Nothing outside the issue's scope changed.

Follow-up (file): the claim checks out in the code. setPageCache (lib/http.ts) gives anonymous viewers public, s-maxage=... on /, /automations, /automations/all, /sync and /pricing. securityHeaders puts a per-request nonce in the CSP header, and _renderer.tsx puts the same nonce on the inline scripts. So an edge cache serves one nonce to every visitor until the entry expires, which weakens script-src 'nonce-...'. This is a real security gap. It is outside this issue, whose out-of-scope list includes other CSP directives, and the branch does not fix it.

loop:verdict run=review-20261102-182938-69 sha=dd7c5225dbbedeac321eca52631320ee9c5f2159 verdict=merge followup=1

Merged main into the branch: 521b01830c344066a7ee477dc8b9f65f52f05ca1.

loop:carry from=dd7c5225dbbedeac321eca52631320ee9c5f2159 sha=521b01830c344066a7ee477dc8b9f65f52f05ca1

Merged onto main as 9564625f86f1d207f1351faeed484bbc4be7ca76.

loop:merged sha=9564625f86f1d207f1351faeed484bbc4be7ca76 head=521b01830c344066a7ee477dc8b9f65f52f05ca1

Filed the follow-up as #93, a new issue, not marked ready, from branch loop/69 (9564625f86f1): fix(security): stop serving one CSP nonce to every visitor of edge-cached pages

loop:filed issue=93

Sign up or Sign in to add to the discussion.