Environment
main @ c909bc7d. Pre-existing since 557a5178, the original port — untouched by #365, #371 and #388.
Description
sanitizeSnippet preserves real <mark> tags by swapping them for NUL-delimited sentinels, escaping everything, then swapping the sentinels back:
const tagOpen = '\0markO\0'
const tagClose = '\0markC\0'
The sentinels are ordinary characters. A snippet that already contains them survives the round trip and comes back out as markup:
sanitizeSnippet('before \0markO\0 after')
// → 'before <mark> after' ← one <mark> in the output, none in the input
sanitizeSnippet('x \0markO\0INJECTED\0markC\0 y')
// → 'x <mark>INJECTED</mark> y'
sanitizeSnippet('\0markO\0<mark>real</mark>')
// → '<mark><mark>real</mark>' ← two opens, one close
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 is right; the mechanism does not hold it, because what decides whether markup is emitted is a string the input can supply.
This is not XSS, and the bound is worth stating precisely
escapeHTML runs before the sentinels are swapped back, so content inside a forged region is still escaped and no attribute can be introduced:
sanitizeSnippet('\0markO\0<script>alert(document.cookie)</script>\0markC\0')
// → '<mark><script>alert(document.cookie)</script></mark>'
sanitizeSnippet('\0markO\0 onload=alert(1)')
// → '<mark> onload=alert(1)' ← the attribute lands outside the tag, as text
Forgeable surface: the two fixed strings <mark> and </mark>, nothing else. No other tag, no attribute, no unescaped content. So the consequence is content spoofing in search results — arbitrary emphasis, or an unbalanced tag reaching v-html — not script execution.
Reachability
sanitizeSnippet is fed from result.snippets.title / .content in src/runtime/composables/useContentSearch.ts:141,144, whose values come from whatever search function the host application passes to <ContentSearch>. If that backend indexes content an outside party can influence, planting the sentinel bytes is trivial.
Suggested fix
Stop choosing a placeholder and hoping it is unique. Split on the real tags, escape everything else:
export function sanitizeSnippet(snippet: string): string {
return snippet
.split(/(<mark>|<\/mark>)/)
.map(part => (part === '<mark>' || part === '</mark>') ? part : escapeHTML(part))
.join('')
}
There is nothing left to forge, because nothing is being guessed. Checked against the six existing sanitizeSnippet cases — all pass — and both collision inputs above become inert.
Upstream
The function came from the original port unchanged, so nuxt/ui very likely carries this too. Not reported there; noting it so the porting direction is on record.
Additional context
Found by an independent security review pass over #371.
Environment
main@c909bc7d. Pre-existing since557a5178, the original port — untouched by #365, #371 and #388.Description
sanitizeSnippetpreserves real<mark>tags by swapping them for NUL-delimited sentinels, escaping everything, then swapping the sentinels back:The sentinels are ordinary characters. A snippet that already contains them survives the round trip and comes back out as markup:
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-htmlthat renders the result." The intent is right; the mechanism does not hold it, because what decides whether markup is emitted is a string the input can supply.This is not XSS, and the bound is worth stating precisely
escapeHTMLruns before the sentinels are swapped back, so content inside a forged region is still escaped and no attribute can be introduced:Forgeable surface: the two fixed strings
<mark>and</mark>, nothing else. No other tag, no attribute, no unescaped content. So the consequence is content spoofing in search results — arbitrary emphasis, or an unbalanced tag reachingv-html— not script execution.Reachability
sanitizeSnippetis fed fromresult.snippets.title/.contentinsrc/runtime/composables/useContentSearch.ts:141,144, whose values come from whateversearchfunction the host application passes to<ContentSearch>. If that backend indexes content an outside party can influence, planting the sentinel bytes is trivial.Suggested fix
Stop choosing a placeholder and hoping it is unique. Split on the real tags, escape everything else:
There is nothing left to forge, because nothing is being guessed. Checked against the six existing
sanitizeSnippetcases — all pass — and both collision inputs above become inert.Upstream
The function came from the original port unchanged, so
nuxt/uivery likely carries this too. Not reported there; noting it so the porting direction is on record.Additional context
Found by an independent security review pass over #371.