Fix: scrub instead of detach for framework-rendered DOM#176
Conversation
Four rules — meta-injection-strip, noscript-strip, html-comment-strip, hidden-text-strip — called .remove() on DOM nodes that the page framework had rendered and was tracking for unmount. When React Router (and equivalents in Vue / Svelte / Astro / htmx head-merge) tried to reconcile those nodes out on route change, removeChild threw inside the commit phase and stranded the route mid-render. User-visible symptom on the demo site: clicking the RiverMart logo from a product detail page updated the URL but never repainted the home content. Switched each rule to blank its data carrier (attribute value, textContent, or Text-node data) rather than detach the carrier node. Element references the framework holds stay valid, agent-readable content still goes to empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
1 issue found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| if (content !== null && content.length > 0 && containsInjection(content)) { | ||
| element.setAttribute("content", ""); |
There was a problem hiding this comment.
The exported description at line 99 still reads "Remove <meta> tags and clear <title> text whose content carries prompt-injection patterns." — but the behavior now blanks the content attribute rather than removing the element. The other three rules in this PR (hidden-text-strip, html-comment-strip, noscript-strip) all had their description property updated from "Remove" to "Blank". This one was missed.
At line 99 update the description to something like:
"Blank <meta> content and <title> text whose value carries prompt-injection patterns.",There was a problem hiding this comment.
Fixed in bf0a0be — the exported description now reads "Blank content and <title> text whose value carries prompt-injection patterns.", matching what the other three converted rules adopted. Good catch — this is exactly the kind of drift the rule sync was supposed to prevent. — Claude Code, on behalf of @twschiller
Sync meta-injection-strip's exported description with its current behavior — the rule blanks the content attribute now, not the element. Matches the wording the other three converted rules adopted in the prior commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reflow docs through mdformat — pre-commit owns markdown wrapping, and my edits in the previous commits used different line widths and table column padding. Pure formatting, no content changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…uts in attribute-injection-sanitize These ARIA attributes surface to the accessibility tree as agent-readable name / description / value / shortcut text, so they're a quiet carrier for injection payloads. Pre-#176 they fell to hidden-text-strip's wrapper detachment; after #176 nothing was scrubbing them. Append the four names to CANDIDATE_ATTRIBUTES; the selector and scrub loop pick them up unchanged. Tests, property-test allowlist, and rule docs updated. Closes #182 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Four rules —
meta-injection-strip,noscript-strip,html-comment-strip,hidden-text-strip— called.remove()on DOM nodes the page framework hadrendered and was tracking for unmount. When React Router (and equivalents in
Vue / Svelte / Astro / htmx head-merge) tried to reconcile those nodes out
on route change,
removeChildthrew inside the commit phase and strandedthe route mid-render.
User-visible symptom on the demo site: clicking the RiverMart logo from a
product detail page updated the URL but never repainted the home content,
because React 19 hoists
<title>and three<meta>tags fromProductDetail.tsxinto<head>,meta-injection-stripdetached them onmatch, and React's next commit tried to
removeChildnodes whoseparentNodewas alreadynull.Each rule now blanks its data carrier (attribute value,
textContent, orText-node data) rather than detaching the carrier node. The elementreferences the framework holds stay valid; agent-readable content still
goes to empty.
meta-injection-strip— blankscontent=""on matching<meta>(waselement.remove()).noscript-strip— blanks<noscript>children viatextContent = ""(was
element.remove()).html-comment-strip— blankscomment.dataonly when the data matchesINJECTION_PATTERNS(was: unconditional removal of every comment outside<script>/<style>/<noscript>). The injection-match gatenaturally preserves React Suspense markers (
$,/$,$?, …) withoutan explicit allowlist.
hidden-text-strip— walksSHOW_TEXTand blanks each Text node'sdata(was
element.remove()). Element / comment nodes inside the hiddensubtree stay where the framework put them.
Property-based tests pin the load-bearing invariants:
html-comment-strip.property.test.ts— across all known injectionfixtures, comments are blanked but stay attached; across the framework
marker set (
$,/$,$?,$!,[,], build stamps, licenseheaders, dev TODOs), comments are left untouched. Catches any future
widening of
INJECTION_PATTERNSthat would re-introduce the navigationcrash via a different path.
hidden-text-strip.property.test.ts— across the cross-product ofhidden-CSS triggers × child-subtree shapes, descendant text is blanked
AND every element node inside the hidden box stays attached. Drift back
to
element.remove()orreplaceChildren()would fail this as a class,not just one specific case.
Docs and skill catalogs updated to describe each rule's current scrub
behavior and to broaden the
stripverb's taxonomy entry.Coverage trade-offs
Switching from "detach the carrier" to "blank the carrier" trades framework
safety for some coverage reduction. Four specific false-negative paths to
be aware of:
meta-injection-strip— in-placecontentupdates. The subtreewatcher observes
id/classattribute changes only, notcontent.If a framework or page script later writes a new poisoned value to
content=on the same<meta>node we blanked, the new value isvisible to agents until the next route change or subtree addition
re-triggers a scan. Pre-fix, the meta was detached, so any framework
write landed on a node nobody was reading.
noscript-strip— in-place children replacement inside a kept<noscript>. The watcher fires on the new children, butstripNoscript'squerySelectorAllwalks downward — it doesn'trecognize that the added subtree's ancestor is a
<noscript>wealready blanked. Pre-fix, the noscript was gone, so any new content had
to bring its own
<noscript>wrapper. Real-world likelihood is verylow: frameworks essentially never re-render noscript children at
runtime.
html-comment-strip— comments not matchingINJECTION_PATTERNSnowsurvive. Largest coverage delta in the PR. The rule went from "strip
every comment outside
<script>/<style>/<noscript>" to "scrubonly comments whose data matches the injection pattern set" (currently
11 patterns). Novel injection phrasings, off-pattern prose, and any
benign-looking comments carrying instruction-shaped text that used to
be removed now stay visible. The narrowing is what makes React Suspense
markers safe automatically (they don't match any pattern) — but it caps
coverage at the recognized shapes.
hidden-text-strip— non-text payloads inside hidden subtreessurvive. The rule used to remove the wrapper, taking image
alt,aria-label,title, SVG<title>/<desc>, anddata-*attributes with it. Now it only blanks Text nodes. Attribute-shaped
payloads inside a hidden box are still caught by
attribute-injection-sanitizeandsvg-text-stripif they matchthose rules' pattern sets, but the broad "wipe everything inside the
box" sweep is gone.
Follow-ups (not in this PR)
OBSERVED_ATTRIBUTESinsubtree-watcher.tsto includecontentand add an upward
closest(\"noscript\")check instripNoscripttoclose (1) and (2).
html-comment-stripshould restore broad-sweep behaviorwith a framework-marker allowlist (skip
$//$/[/]-prefixeddata) to close (3). Kept out of this PR because the allowlist
enumeration is its own design discussion.
INJECTION_PATTERNScoverage on attribute-targeted rules now thathidden-text-stripno longer back-stops them (4).Test plan
bun run checkinextension/cleanbun run testinextension/: 1380 / 1380 passing across 77 suitesback to home via the RiverMart logo; confirm Home renders (was: URL
updated but content frozen on Product Detail)
navigation should still work either way
(any Next.js 14+ deploy)
🤖 Generated with Claude Code