Skip to content

ContentSearch: sanitizeSnippet reconstructs <mark> from input, so a snippet can forge highlight tags #391

Description

@IgorShevchik

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>&lt;script&gt;alert(document.cookie)&lt;/script&gt;</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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions