Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/db/src/search-repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
PostgresSearchRepository,
SEARCH_DOCUMENT_VERSION,
indexedSubjectSql,
renderExcerptHtml,
searchVectorSql,
} from './search-repo'

Expand Down Expand Up @@ -256,6 +257,23 @@ describe('matching and ranking', () => {
expect(results.hits[0]?.excerpt).toMatch(/<b>/)
})

it('escapes HTML in a body excerpt so an injected payload is inert', async () => {
await seed({ id: 1, message: 'before <img src=x onerror=alert(1)> a kestrel flew past' })

const excerpt = (await repo.search(query(), scope())).hits[0]?.excerpt ?? ''
expect(excerpt).not.toContain('<img')
expect(excerpt).toContain('&lt;img src=x onerror=alert(1)&gt;')
expect(excerpt).toMatch(/<b>kestrel<\/b>/i)
})

it('leaves no tag in an excerpt other than the highlight marks', async () => {
await seed({ id: 1, message: 'kestrel "><svg onload=alert(1)></svg>' })

const excerpt = (await repo.search(query(), scope())).hits[0]?.excerpt ?? ''
expect(excerpt).not.toContain('<svg')
expect(excerpt).not.toMatch(/<(?!\/?b>)/)
})

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([])
Expand Down Expand Up @@ -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 = `<script>alert(1)</script> ${start}kestrel${stop}`
expect(renderExcerptHtml(raw)).toBe('&lt;script&gt;alert(1)&lt;/script&gt; <b>kestrel</b>')
})

it('escapes quotes and ampersands so an attribute cannot break out', () => {
expect(renderExcerptHtml('a & b "><svg>')).toBe('a &amp; b &quot;&gt;&lt;svg&gt;')
})
})
31 changes: 29 additions & 2 deletions packages/db/src/search-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}

export function renderExcerptHtml(headline: string): string {
const escaped = headline.replace(
/[&<>"']/g,
(character) => EXCERPT_HTML_ESCAPES[character] ?? character,
)
return escaped
.split(EXCERPT_HIGHLIGHT_START)
.join('<b>')
.split(EXCERPT_HIGHLIGHT_STOP)
.join('</b>')
}

export function searchVectorSql(subject: SQL | string, message: SQL | string): SQL {
return sql`
setweight(to_tsvector(${SEARCH_CONFIG}, coalesce(${subject}, '')), 'A') ||
Expand Down Expand Up @@ -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}
Expand All @@ -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),
}))

Expand Down
Loading