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), }))