Skip to content

fix(ContentSearch): stop sanitizeSnippet rebuilding tags from its input - #405

Merged
IgorShevchik merged 2 commits into
mainfrom
fix/sanitize-snippet-sentinel
Aug 15, 2026
Merged

fix(ContentSearch): stop sanitizeSnippet rebuilding tags from its input#405
IgorShevchik merged 2 commits into
mainfrom
fix/sanitize-snippet-sentinel

Conversation

@IgorShevchik

@IgorShevchik IgorShevchik commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Linked issue

Closes #391.

Type of change

  • Documentation (updates to the documentation or readme)
  • Bug fix (a non-breaking change that fixes an issue)
  • Enhancement (improving an existing functionality)
  • New feature (a non-breaking change that adds functionality)
  • Chore (updates to the build process or auxiliary tools and libraries)
  • Breaking change (fix or feature that would cause existing functionality to change)

Description

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

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:

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 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

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 a capture group — no attribute could land inside it:

sanitizeSnippet('\0markO\0 onload=alert(1)')
// → '<mark> onload=alert(1)'     the attribute lands outside, as text

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 reaching v-html.

Reachability

sanitizeSnippet is fed from result.snippets.title / .content in useContentSearch.ts:141,144, whose values come from whatever search function the host application passes to <ContentSearch>, and reaches v-html at CommandPalette.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 split nor replaceAll: 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:

mutation failing tests
regex capture dropped, so tags vanish from the output 6
closing-tag half of the condition dropped 6
tags escaped along with everything else 6
old placeholder implementation restored 4 — exactly the forgery cases

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. eslint and vue-tsc --noEmit clean.

Corrections made during review

  • The defect description understated the trigger and the consequence (above).
  • An earlier revision of this table claimed 4/4/4/3. Both that and a reviewer's independent estimate of 2/2/2/3 were wrong; the numbers above come from applying each mutation and reading the failures from the JSON reporter. The first figure came from a name-extraction bug — the test names contain <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, so nuxt/ui very likely carries this too — not reported there.

Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

…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
IgorShevchik force-pushed the fix/sanitize-snippet-sentinel branch from 3f4eb85 to c4a851c Compare August 15, 2026 05:50
@IgorShevchik
IgorShevchik merged commit fe4a466 into main Aug 15, 2026
1 check passed
@IgorShevchik
IgorShevchik deleted the fix/sanitize-snippet-sentinel branch August 15, 2026 06:00
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
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.

ContentSearch: sanitizeSnippet reconstructs &lt;mark&gt; from input, so a snippet can forge highlight tags

2 participants