From 2f0bca8e6b3fcaf68a72e82fd753d19d0c92714b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 21:25:33 +0000 Subject: [PATCH] fix(search): escape search excerpts to prevent stored XSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 marks, so the only markup an excerpt can carry is the highlight itself. Refs MEI-5 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YUFhgfBWRXg9meWLDhhn6R --- packages/db/src/search-repo.test.ts | 32 +++++++++++++++++++++++++++++ packages/db/src/search-repo.ts | 31 ++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/db/src/search-repo.test.ts b/packages/db/src/search-repo.test.ts index 6344a11c..5ea48b97 100644 --- a/packages/db/src/search-repo.test.ts +++ b/packages/db/src/search-repo.test.ts @@ -10,6 +10,7 @@ import { PostgresSearchRepository, SEARCH_DOCUMENT_VERSION, indexedSubjectSql, + renderExcerptHtml, searchVectorSql, } from './search-repo' @@ -256,6 +257,23 @@ describe('matching and ranking', () => { expect(results.hits[0]?.excerpt).toMatch(//) }) + it('escapes HTML in a body excerpt so an injected payload is inert', async () => { + await seed({ id: 1, message: 'before a kestrel flew past' }) + + const excerpt = (await repo.search(query(), scope())).hits[0]?.excerpt ?? '' + expect(excerpt).not.toContain('kestrel<\/b>/i) + }) + + it('leaves no tag in an excerpt other than the highlight marks', async () => { + await seed({ id: 1, message: 'kestrel ">' }) + + const excerpt = (await repo.search(query(), scope())).hits[0]?.excerpt ?? '' + expect(excerpt).not.toContain(')/) + }) + it('runs no query at all for empty terms', async () => { await seed({ id: 1, message: 'kestrel' }) expect((await repo.search(query({ terms: ' ' }), scope())).hits).toEqual([]) @@ -536,3 +554,17 @@ describe('reindexing', () => { expect(await repo.indexProgress()).toEqual({ indexed: 1, pending: 3 }) }) }) + +describe('renderExcerptHtml', () => { + const start = String.fromCharCode(0xe000) + const stop = String.fromCharCode(0xe001) + + it('escapes HTML and renders only the highlight sentinels as marks', () => { + const raw = ` ${start}kestrel${stop}` + expect(renderExcerptHtml(raw)).toBe('<script>alert(1)</script> kestrel') + }) + + it('escapes quotes and ampersands so an attribute cannot break out', () => { + expect(renderExcerptHtml('a & b ">')).toBe('a & b "><svg>') + }) +}) diff --git a/packages/db/src/search-repo.ts b/packages/db/src/search-repo.ts index 7d785c99..bc16c66f 100644 --- a/packages/db/src/search-repo.ts +++ b/packages/db/src/search-repo.ts @@ -23,6 +23,33 @@ const TOP_AUTHORS = 24 export const SEARCH_DOCUMENT_VERSION = 1 +const EXCERPT_HIGHLIGHT_START = String.fromCharCode(0xe000) +const EXCERPT_HIGHLIGHT_STOP = String.fromCharCode(0xe001) + +const HEADLINE_OPTIONS = + `StartSel=${EXCERPT_HIGHLIGHT_START}, StopSel=${EXCERPT_HIGHLIGHT_STOP}, ` + + `MaxFragments=1, MaxWords=40, MinWords=15` + +const EXCERPT_HTML_ESCAPES: Readonly> = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +} + +export function renderExcerptHtml(headline: string): string { + const escaped = headline.replace( + /[&<>"']/g, + (character) => EXCERPT_HTML_ESCAPES[character] ?? character, + ) + return escaped + .split(EXCERPT_HIGHLIGHT_START) + .join('') + .split(EXCERPT_HIGHLIGHT_STOP) + .join('') +} + export function searchVectorSql(subject: SQL | string, message: SQL | string): SQL { return sql` setweight(to_tsvector(${SEARCH_CONFIG}, coalesce(${subject}, '')), 'A') || @@ -78,7 +105,7 @@ export class PostgresSearchRepository { author_user_id, author_username, created_at, rank, ts_headline(${SEARCH_CONFIG}, excerpt_source, websearch_to_tsquery(${SEARCH_CONFIG}, ${query.terms}), - 'MaxFragments=1, MaxWords=40, MinWords=15') as excerpt + ${HEADLINE_OPTIONS}) as excerpt from ${candidates} order by ${finalOrder} limit ${query.limit} @@ -94,7 +121,7 @@ export class PostgresSearchRepository { authorUserId: row.author_user_id === null ? null : Number(row.author_user_id), authorUsername: String(row.author_username), postedAt: row.created_at instanceof Date ? row.created_at : new Date(String(row.created_at)), - excerpt: String(row.excerpt ?? ''), + excerpt: renderExcerptHtml(String(row.excerpt ?? '')), rank: Number(row.rank), }))