fix(ContentSearch): stop sanitizeSnippet rebuilding tags from its input - #405
Merged
Conversation
IgorShevchik
force-pushed
the
fix/sanitize-snippet-sentinel
branch
from
August 15, 2026 05:26
f818d00 to
3f4eb85
Compare
…nput
`sanitizeSnippet` preserved real `<mark>` tags by swapping them for
`\0markO\0`/`\0markC\0`, escaping everything, then swapping back. NUL is an
ordinary character a snippet can carry, so what decided whether markup was
emitted was a string the input could supply.
A whole sentinel forged a tag outright:
sanitizeSnippet('before \0markO\0 after')
// → 'before <mark> after' — one <mark> out, none in
sanitizeSnippet('\0markC\0\0markO\0')
// → '</mark><mark>' — a close ahead of its opener
Worse, and easier: **six** of the seven bytes, immediately before a *real* tag,
were enough. The placeholder this function inserted for that tag completed the
prefix, and the restore step then found a sentinel spanning the two:
sanitizeSnippet('a\0markO<mark>hit</mark>b')
// → 'a<mark>markO\0hit</mark>b'
The genuine highlight moved, and with text on both sides it landed on text it
was never meant to mark. No crafted sequence — one stray fragment ahead of any
highlight. The first description of this defect, in #391 and in this branch's
first revision, said the input had to carry the sentinel itself. That understated
both the trigger and the consequence; an independent fuzzing pass found the
prefix case.
The function's own doc says the preserved tag is hardcoded *"on purpose: taking
it as a parameter would let a caller pass any tag through to the `v-html` that
renders the result."* The intent was right; the mechanism did not hold it.
Not XSS, and the bound is worth stating because it is what keeps this a content
bug: `escapeHTML` ran before the swap back, so content inside a forged region
stayed escaped, and the emitted tag came from a fixed literal rather than from a
capture group — no attribute could land inside it. Forgeable surface: the two
strings, nothing else. The consequence is spoofed emphasis, relocated highlights
and unbalanced markup reaching `v-html`, in snippets that come from whatever
backend the host application passes to `<ContentSearch>`
(`useContentSearch.ts:141,144`).
Splitting on the real tags removes the guess, and with it the collision. Both
implementations were run over ~2.1M generated inputs by an independent pass:
every divergence traces to this family, and the new one never emitted a tag
other than the two literals nor leaked an unescaped character. The regex has no
quantifiers, and growth is linear to 61MB.
Five tests added, each mutation-checked: dropping the regex capture, dropping the
closing-tag half of the condition, and escaping the tags themselves are all
caught; restoring the old implementation fails exactly the four forgery cases.
One pins the *bound* rather than the bug — a forged tag could never carry an
attribute — so a future rewrite cannot widen that silently.
Pre-existing since `557a5178`, the original port, and untouched by #365, #371
and #388. The function came from upstream unchanged, so `nuxt/ui` very likely
carries it too.
Closes #391.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWWrBHgfqGSbeU3V6UuMF8
IgorShevchik
force-pushed
the
fix/sanitize-snippet-sentinel
branch
from
August 15, 2026 05:50
3f4eb85 to
c4a851c
Compare
8 tasks
IgorShevchik
pushed a commit
that referenced
this pull request
Aug 15, 2026
…erage Each was falsifiable, which is how each was caught. The pattern is the finding: a prose claim about test coverage cannot fail when it stops being true. **"Every constant and every branch above was verified by removing it and watching a named test fail."** #390 found six surviving mutations, three of them constants this bullet covers. Restated as intent, with the procedure spelled out rather than implied — delete, run `pnpm test`, confirm a named test goes red, revert — and with the other three survivors named, since a paragraph rebuilding trust in coverage should not leave half its own evidence unaccounted for. **"The unpaired-surrogate strings are the only input that can catch a surrogate range constant being widened."** True only where the probe sits one code point outside the bound it pins. Two of the four sat `0x100` away and caught nothing. **"A pickaxe returns exactly one commit."** It counts occurrences of the string, not authorship, so `54b93e33`'s jsDoc line joined the list and any future comment naming the parameter will too. Restating the number would only defer the problem; the claim now attributes rather than counts, in all three places it appeared. Two smaller corrections: the `8 of 66` figure appears nowhere in the repository and cannot be re-derived, and `createClusterSnapper` gained a second parameter in #388. Adds one §2 invariant — **`sanitizeSnippet` splits on the tag**. Upstream's placeholder round-trip is what this file was ported from, and it lets a snippet forge `<mark>` out of its own input: six of the sentinel's seven bytes ahead of a real tag suffice, because the placeholder inserted for that tag completes the prefix, moving a genuine highlight onto text it was never meant to mark (#391, fixed in #405). Carries the sibling rule from the function's jsDoc too — the tag is hardcoded so a caller cannot pass any tag through to `v-html` — since a port that generalises the signature breaks the other half of the same guarantee. Two things found while writing it, both worth more than the corrections: `getGraphemeSegmenter()`'s module-level memo is a second cache, distinct from the per-value `segments` view, and was documented nowhere. Collapsing either into per-call construction costs a search box every keystroke and fails no test — the module-level one has no coverage at all. Now named. The four sub-bullets restated numbers that also live in the code comments, and one such figure has already rotted in one of its two homes. They now point at the code rather than copying it, which is why this correction pass removes 17 lines as well as adding. No `src/` change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWWrBHgfqGSbeU3V6UuMF8
IgorShevchik
pushed a commit
that referenced
this pull request
Aug 15, 2026
…erage Each was falsifiable, which is how each was caught. The pattern is the finding: a prose claim about test coverage cannot fail when it stops being true. **"Every constant and every branch above was verified by removing it and watching a named test fail."** #390 found six surviving mutations, three of them constants this bullet covers. Restated as intent, with the procedure spelled out rather than implied — delete, run `pnpm test`, confirm a named test goes red, revert — and with the other three survivors named, since a paragraph rebuilding trust in coverage should not leave half its own evidence unaccounted for. **"The unpaired-surrogate strings are the only input that can catch a surrogate range constant being widened."** True only where the probe sits one code point outside the bound it pins. Two of the four sat `0x100` away and caught nothing. **"A pickaxe returns exactly one commit."** It counts occurrences of the string, not authorship, so `54b93e33`'s jsDoc line joined the list and any future comment naming the parameter will too. Restating the number would only defer the problem; the claim now attributes rather than counts, in all three places it appeared. Two smaller corrections: the `8 of 66` figure appears nowhere in the repository and cannot be re-derived, and `createClusterSnapper` gained a second parameter in #388. Adds one §2 invariant — **`sanitizeSnippet` splits on the tag**. Upstream's placeholder round-trip is what this file was ported from, and it lets a snippet forge `<mark>` out of its own input: six of the sentinel's seven bytes ahead of a real tag suffice, because the placeholder inserted for that tag completes the prefix, moving a genuine highlight onto text it was never meant to mark (#391, fixed in #405). Carries the sibling rule from the function's jsDoc too — the tag is hardcoded so a caller cannot pass any tag through to `v-html` — since a port that generalises the signature breaks the other half of the same guarantee. Two things found while writing it, both worth more than the corrections: `getGraphemeSegmenter()`'s module-level memo is a second cache, distinct from the per-value `segments` view, and was documented nowhere. Collapsing either into per-call construction costs a search box every keystroke and fails no test — the module-level one has no coverage at all. Now named. The four sub-bullets restated numbers that also live in the code comments, and one such figure has already rotted in one of its two homes. They now point at the code rather than copying it, which is why this correction pass removes 17 lines as well as adding. No `src/` change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWWrBHgfqGSbeU3V6UuMF8
IgorShevchik
pushed a commit
that referenced
this pull request
Aug 15, 2026
Each was falsifiable, which is how each was caught. The pattern is the finding: a prose claim about test coverage cannot fail when it stops being true. **"Every constant and every branch above was verified by removing it and watching a named test fail."** #390 found six surviving mutations, three of them constants this bullet covers. Restated as intent, with the procedure spelled out rather than implied — delete, run `pnpm test`, confirm a named test goes red, revert — and with the other three survivors named, since a paragraph rebuilding trust in coverage should not leave half its own evidence unaccounted for. **"The unpaired-surrogate strings are the only input that can catch a surrogate range constant being widened."** True only where the probe sits one code point outside the bound it pins. Two of the four sat `0x100` away and caught nothing. **"A pickaxe returns exactly one commit."** It counts occurrences of the string, not authorship, so `54b93e33`'s jsDoc line joined the list and any future comment naming the parameter will too. Restating the number would only defer the problem; the claim now attributes rather than counts, in all three places it appeared. **"Only 16 of ~3200 commits carry an `Upstream:` trailer."** 52 do. Checked two ways — `git log --grep` and a pass over every commit body — and against the tree as it stood when the sentence was written, where it was already 52 of 3179. It was never right, and it is load-bearing: it is the stated reason the trailer convention cannot support an inference about provenance. That conclusion still holds at 52 of 3200; the number does not. Corrected in all three places. Two smaller ones: the `8 of 66` figure appears nowhere in the repository and cannot be re-derived, and `createClusterSnapper` gained a second parameter in #388. The guard list omitted `describe('truncation from the start')`, which pins the surrogate safety of `truncateHTMLFromStart` — a function this same bullet names. Adds one §2 invariant — **`sanitizeSnippet` splits on the tag**. Upstream's placeholder round-trip is what this file was ported from, and it lets a snippet forge `<mark>` out of its own input: six of the sentinel's seven bytes ahead of a real tag suffice, because the placeholder inserted for that tag completes the prefix, moving a genuine highlight onto text it was never meant to mark (#391, fixed in #405). Two things found while writing it, both worth more than the corrections: `getGraphemeSegmenter()`'s module-level memo is a second cache, distinct from the per-value `segments` view, and was documented nowhere. Collapsing either into per-call construction costs a search box every keystroke and fails no test — the module-level one has no coverage at all. Now named. The four sub-bullets restated numbers that also live in the code comments, and one such figure has already rotted in one of its two homes. They now point at the code rather than copying it, which is why this pass removes 31 lines as well as adding. No `src/` change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWWrBHgfqGSbeU3V6UuMF8
IgorShevchik
added a commit
that referenced
this pull request
Aug 15, 2026
…age (#409) Each was falsifiable, which is how each was caught. The pattern is the finding: a prose claim about test coverage cannot fail when it stops being true. **"Every constant and every branch above was verified by removing it and watching a named test fail."** #390 found six surviving mutations, three of them constants this bullet covers. Restated as intent, with the procedure spelled out rather than implied, and the other three survivors named — two of which are pinned by `describe('key selection')`, credited so the trail does not end nowhere. **"The unpaired-surrogate strings are the only input that can catch a surrogate range constant being widened."** True only where the probe sits one code point outside the bound it pins. Two of the four sat 0x100 away and caught nothing. **"A pickaxe returns exactly one commit."** It counts occurrences of the string, not authorship, so `54b93e33`'s jsDoc line joined the list and any future comment naming the parameter will too. The claim now attributes rather than counts. **"Only 16 of ~3200 commits carry an `Upstream:` trailer."** 52 do, and 52 of 3179 did when the sentence was written — it was wrong on arrival, not stale. It is load-bearing: it is the stated reason the trailer cannot support a provenance inference. The conclusion survives the correction; the number does not. Also: the unreproducible `8 of 66` figure is marked as a one-off measurement, `createClusterSnapper`'s signature gained a second parameter in #388, the guard list omitted `describe('truncation from the start')`, and `getGraphemeSegmenter()`'s module-level memo — a second cache, distinct from the per-value view, covered by no test — was documented nowhere. Adds one §2 invariant: **`sanitizeSnippet` splits on the tag**. Upstream's placeholder round-trip lets a snippet forge `<mark>` out of its own input, so a port that replays upstream reverts #405. Not reported upstream, so the conflict recurs on every port that touches the function. The four sub-bullets that restated numbers also living in the code comments now point at the code instead — one of those figures had already rotted in one of its two homes. Verified by a mutation pass over every constant and branch in the file: 62 mutations, 55 killed, 5 provably equivalent, 2 real gaps, filed as #410 and #411. Running the check this file now prescribes is what found them. No `src/` change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWWrBHgfqGSbeU3V6UuMF8
This was referenced Aug 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Linked issue
Closes #391.
Type of change
Description
sanitizeSnippetpreserved real<mark>tags by swapping them for\0markO\0/\0markC\0, escaping everything, then swapping back. NUL is an ordinary character a snippet can carry, so what decided whether markup was emitted was a string the input could supply.A whole sentinel forged a tag outright:
The trigger is lower than that, and the consequence worse
Six of the seven bytes, immediately before a real tag, were enough. The placeholder the function inserted for that tag completed the prefix, and the restore step then found a sentinel spanning the two:
The genuine highlight moved, and with text on both sides it landed on text it was never meant to mark. No crafted sequence — one stray fragment ahead of any highlight.
The first revision of this description, and #391, both said the input had to carry the sentinel itself. That understated both the trigger and the consequence. An independent fuzzing pass over ~2.1M generated inputs found the prefix case; it is fixture-pinned now.
Not XSS — and the bound is the point
escapeHTMLran before the swap back, so content inside a forged region stayed escaped, and the emitted tag came from a fixed literal rather than a capture group — no attribute could land inside it:Forgeable surface: the two strings
<mark>and</mark>. No other tag, no attribute, no unescaped content. The consequence is spoofed emphasis, relocated highlights, and unbalanced markup reachingv-html.Reachability
sanitizeSnippetis fed fromresult.snippets.title/.contentinuseContentSearch.ts:141,144, whose values come from whateversearchfunction the host application passes to<ContentSearch>, and reachesv-htmlatCommandPalette.vue:623,649.Fix
Split on the real tags instead of guessing a placeholder. Nothing is guessed, so nothing can collide.
Independently fuzzed at ~2.1M inputs against both the old implementation and a hand-written reference scanner that uses neither
splitnorreplaceAll: no bypass, no unescaped character, no tag other than the two literals, and every old/new divergence traced to this defect family. The regex has no quantifiers; growth measured linear to 61MB, and at realistic snippet size the cost is 2.48µs against the old 1.78µs per call.Tests
Five added. Mutation-checked by running each against the suite:
One of the five pins the bound rather than the bug — a forged tag could never carry an attribute — so a future rewrite cannot widen that without failing.
vitest run test/utils/ test/components/→ 6093 passed, 6 skipped, 255 files.eslintandvue-tsc --noEmitclean.Corrections made during review
<mark>, and the extraction split on>.Provenance
Pre-existing since
557a5178, the original port; untouched by #365, #371 and #388. Found by an independent security review pass over #371. The function came from upstream unchanged, sonuxt/uivery likely carries this too — not reported there.Checklist