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
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,22 @@ URLs work without any DSN.
Policy stays tight when Sentry is off. The middleware also unconditionally
appends `https://static.cloudflareinsights.com` to `script-src` (see
next bullet — Cloudflare Web Analytics).
- **Cloudflare Web Analytics injects a beacon into every `text/html`
response at the edge — including user drops at `/p/:slug/raw`.** It's
automatic zone-level injection, not something the Worker does, and it's
only visible when the request sends `Accept: text/html` (a plain
`Accept: */*` fetch returns clean bytes, which is why it's easy to miss).
**This is accepted deliberately** — the user was asked and chose to keep
it. Don't "fix" it, and don't read the Sentry rule above ("we never
inject into user content") as covering it; that rule is about what the
Worker adds, not what the edge does.
**The one place it had to be stopped is the pattern skeleton.** Served
as `text/html`, the beacon lands in the template an agent then fills in
and *uploads*, so it becomes stored drop content carrying a pinned SRI
hash that breaks when the beacon rotates — and it contradicts the
pattern's own zero-`<script>` rule. `getPatternAsset()` therefore serves
templates as `text/plain`; non-HTML responses aren't rewritten. Keep it
that way. The e2e asserts the served bytes, not just the content type.
- **Config:** `SENTRY_DSN` is a Worker secret. Because this Worker
uses versioned deploys (CI runs `wrangler versions upload` on PRs),
use `wrangler versions secret put SENTRY_DSN` — *not* the plain
Expand Down
28 changes: 21 additions & 7 deletions scripts/agent-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -114,20 +114,34 @@ assert_json "$TMP/patterns.json" \
'true' "session-explainer advertises a template_url"

TPL_URL="$BASE/.well-known/patterns/session-explainer.template.html"
# text/plain on purpose — see getPatternAsset(). A text/html response gets the
# Cloudflare Web Analytics beacon appended at the edge, and an agent filling in
# the skeleton would upload that <script> into the drop body.
CT_TPL=$(curl -s -o /dev/null -w "%{content_type}" "$TPL_URL")
assert_contains "$CT_TPL" "text/html" "template served as text/html"
assert_contains "$CT_TPL" "text/plain" "template served as text/plain (blocks HTML rewriting)"
curl -s "$TPL_URL" -o "$TMP/session-template.html"

# Fetch again the way a browser or an HTML-expecting agent would. This is the
# request shape that triggers edge injection, so assert on the *served bytes*
# rather than on the content type — checking the header alone missed this once.
curl -s -H "Accept: text/html" "$TPL_URL" -o "$TMP/session-template-htmlaccept.html"
for variant in "session-template.html" "session-template-htmlaccept.html"; do
if grep -q "<script" "$TMP/$variant"; then
fail "template scripts ($variant)" "found <script> — pattern requires zero"
else
ok "template has zero script tags ($variant)"
fi
done
if cmp -s "$TMP/session-template.html" "$TMP/session-template-htmlaccept.html"; then
ok "template bytes identical regardless of Accept header"
else
fail "template stability" "Accept: text/html changed the response body"
fi
for landmark in 'class="rail"' 'id="p1"' 'id="p2"' 'id="dead-1"' 'BRAND TOKENS' 'STRUCTURE'; do
grep -q "$landmark" "$TMP/session-template.html" \
&& ok "template contains $landmark" \
|| fail "template $landmark" "missing"
done
# The pattern forbids JS; the skeleton must not smuggle any in.
if grep -q "<script" "$TMP/session-template.html"; then
fail "template script tags" "found <script>"
else
ok "template ships zero script tags"
fi

# A template filename that belongs to no pattern is still a canonical 404
NF_TPL=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/.well-known/patterns/nope.template.html")
Expand Down
10 changes: 9 additions & 1 deletion src/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,8 +805,16 @@ export function getPatternAsset(
const match = PATTERNS.find(
(p) => p.template && p.meta.template === filename,
);
// Deliberately text/plain, not text/html. Cloudflare's automatic HTML
// rewriting appends the Web Analytics beacon to any text/html response, and
// an agent that fills in this skeleton would upload that <script> as part of
// the drop body — stored user content, with a pinned SRI hash that breaks
// when the beacon rotates. It also contradicts the pattern's own rule of
// zero script tags. The skeleton is source to copy, not a page to render, so
// text/plain is both the fix and the honest content type. (Same reason the
// .md above is unaffected: non-HTML responses aren't rewritten.)
return match?.template
? { body: match.template, contentType: "text/html; charset=utf-8" }
? { body: match.template, contentType: "text/plain; charset=utf-8" }
: null;
}

Expand Down
Loading