fix(search): escape search excerpts to prevent stored XSS - #105
Merged
Conversation
ts_headline ran over raw post source and emitted its input unescaped into result excerpts, which both the board and the public API render as HTML — so a post body containing markup executed when it surfaced in search. Highlight matches with private-use sentinels, HTML-escape the whole fragment, then restore the sentinels as <b> marks, so the only markup an excerpt can carry is the highlight itself. Refs MEI-5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YUFhgfBWRXg9meWLDhhn6R
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.
What & why
Fixes MEI-5 — a stored XSS in search result excerpts.
PostgresSearchRepository.searchbuilt each excerpt withts_headlineover the raw post source (p.message, or the title for a titles-scoped search).ts_headlineonly wraps matches in delimiters; it does not HTML-escape its input. That excerpt is then rendered withdangerouslySetInnerHTMLon the search results page (app/(board)/search/[token]/page.tsx) and returned verbatim by the public API (api/v1/.../route.ts). So a post body such as'<img src=x onerror=…>'renders harmlessly in a thread but executes when it surfaces in search.script-src 'unsafe-inline'in the CSP means inline handlers are not blocked.The fix
Fixed at the source (
packages/db/src/search-repo.ts) so every consumer — all themes and the API — is protected without per-theme changes:ts_headlinenow marks matches with private-use sentinel characters (U+E000/U+E001) viaStartSel/StopSelinstead of<b>/</b>.renderExcerptHtmlHTML-escapes the entire returned fragment, then replaces the sentinels with<b>/</b>.<b>highlight styling is unchanged.Tests
<img onerror>body is escaped, and no tag other than the highlight survives an attribute-breakout payload.renderExcerptHtml(escaping + sentinel handling).pnpm verifypasses locally (all typecheck projects; 5584 tests).Notes
The Linear issue referenced a stale doc comment at
theme-kit/src/view-models.ts:1080claiming the excerpt is stripped — that comment does not exist in the current tree (it describes a different, already-safe excerpt), so nothing there needed changing. There is a singlets_headlinesite, not two.Refs MEI-5
Generated by Claude Code