Skip to content

fix(search): escape search excerpts to prevent stored XSS - #105

Merged
jouwdan merged 2 commits into
mainfrom
claude/mei-5-search-excerpt-xss
Aug 14, 2026
Merged

fix(search): escape search excerpts to prevent stored XSS#105
jouwdan merged 2 commits into
mainfrom
claude/mei-5-search-excerpt-xss

Conversation

@jouwdan

@jouwdan jouwdan commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

What & why

Fixes MEI-5 — a stored XSS in search result excerpts.

PostgresSearchRepository.search built each excerpt with ts_headline over the raw post source (p.message, or the title for a titles-scoped search). ts_headline only wraps matches in delimiters; it does not HTML-escape its input. That excerpt is then rendered with dangerouslySetInnerHTML on 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_headline now marks matches with private-use sentinel characters (U+E000/U+E001) via StartSel/StopSel instead of <b>/</b>.
  • renderExcerptHtml HTML-escapes the entire returned fragment, then replaces the sentinels with <b>/</b>.
  • The only markup an excerpt can now contain is the highlight itself; all user content is inert. Existing <b> highlight styling is unchanged.

Tests

  • Two end-to-end tests against the real query (PGlite): an <img onerror> body is escaped, and no tag other than the highlight survives an attribute-breakout payload.
  • Two unit tests for renderExcerptHtml (escaping + sentinel handling).
  • Full pnpm verify passes locally (all typecheck projects; 5584 tests).

Notes

The Linear issue referenced a stale doc comment at theme-kit/src/view-models.ts:1080 claiming 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 single ts_headline site, not two.

Refs MEI-5


Generated by Claude Code

claude and others added 2 commits August 14, 2026 21:25
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
@jouwdan
jouwdan merged commit 22eeedd into main Aug 14, 2026
10 checks passed
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.

2 participants