Skip to content

patterns: serve the session-explainer template as text/plain - #45

Merged
utsengar merged 1 commit into
mainfrom
template-no-html-rewrite
Aug 23, 2026
Merged

patterns: serve the session-explainer template as text/plain#45
utsengar merged 1 commit into
mainfrom
template-no-html-rewrite

Conversation

@utsengar

Copy link
Copy Markdown
Owner

The bug

Cloudflare Web Analytics appends its beacon to every text/html response at the edge. The session-explainer skeleton was served as text/html, so it came back with a <script> in it.

Found it by accident during the CLI release: an online patterns init wrote a template 359 bytes larger than the repo source. The diff was one line.

$ curl -H 'Accept: */*'       …/session-explainer.template.html | grep -c cloudflareinsights
0
$ curl -H 'Accept: text/html' …/session-explainer.template.html | grep -c cloudflareinsights
1

Only visible when the request signals it wants HTML, which is why it slipped past the existing checks.

Why it matters more for the skeleton than for a page

The skeleton is the one HTML response here that an agent copies rather than renders. So the injected script stops being a transient edge addition and becomes part of the drop body the agent uploads:

  • It becomes stored user content. The tag carries a pinned SRI hash and a versioned URL. When Cloudflare rotates the beacon, every baked copy fails integrity and sits there dead.
  • Drops built from it carry two beacons — one baked in, one injected per response.
  • It contradicts the pattern's own contract. session-explainer.md requires zero <script> tags and its conformance checklist asserts script.length === 0. An agent checking the skeleton it just fetched would fail before writing a line.

The fix

getPatternAsset() serves templates as text/plain; charset=utf-8. Non-HTML responses aren't rewritten — the .md served from the same route was never affected, which is direct evidence from this zone.

A skeleton is source to copy, not a page to render, so text/plain is both the fix and the more honest content type.

Not fixed, on purpose

Edge injection into user drops at /p/:slug/raw is left alone. I raised it and the call was to keep it. CLAUDE.md now records that decision, because the existing Sentry note ("we never inject into user-published drop HTML") reads like it forbids this — that rule is about what the Worker adds, not what the edge does, and a future session shouldn't "fix" it.

Test change

The old assertion checked the response content type, and separately checked the source file for scripts. Both passed while the served response had a script in it. Now it asserts the served bytes, and fetches a second time with Accept: text/html since that's the request shape that triggers injection:

✓ template served as text/plain (blocks HTML rewriting)
✓ template has zero script tags (session-template.html)
✓ template has zero script tags (session-template-htmlaccept.html)
✓ template bytes identical regardless of Accept header

Verification

  • npx tsc --noEmit clean
  • npm run test:e2eall 148 checks pass (was 146)
  • Will confirm on the preview that Accept: text/html no longer changes the bytes

🤖 Generated with Claude Code

…g it

Cloudflare Web Analytics appends its beacon to every text/html response at
the edge. That was landing inside the session-explainer skeleton, and the
skeleton is the one HTML response here that an agent copies rather than
renders — so the <script> ended up in the drop body the agent uploads.

Three things wrong with that, in order of how long they last:

- It becomes stored user content, not a transient edge addition. The tag
  carries a pinned SRI hash and a versioned URL; when the beacon rotates,
  every baked copy fails integrity and sits there dead.
- Drops built from the skeleton carry two beacons, one baked and one
  injected per-response.
- It contradicts the pattern's own rule of zero script tags. An agent
  running the conformance checklist against the skeleton it just fetched
  would fail before writing a line.

A skeleton is source to copy, not a page to render, so text/plain is both
the fix and the more honest content type. The .md served beside it was
never affected, which is direct evidence from this zone that non-HTML
responses aren't rewritten.

Edge injection into user drops at /p/:slug/raw is left alone on purpose —
raised with the user, who chose to keep it. CLAUDE.md records that so a
future session doesn't read the Sentry rule ("we never inject into user
content") as covering it. That rule is about what the Worker adds.

The e2e now asserts the served bytes rather than the content type, and
fetches a second time with `Accept: text/html` because that request shape
is what triggers injection. The previous assertion checked the header and
checked the source file for scripts, so it passed while the served
response had one. Third time this session a check measured a proxy for
the thing instead of the thing.

Verified: tsc clean, 148 e2e checks pass (was 146).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🚀 Cloudflare preview

URL: https://40b8e61b-htmlbin.utkarsh2012.workers.dev

Built from commit 528c24d968d8db05397f44418ab64b761a151415.

Note: bindings (D1, KV, AI) are shared with production.
Avoid destructive PR tests on real data.

@utsengar
utsengar merged commit bcb609f into main Aug 23, 2026
1 check passed
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