From 0c2fc83db83d8a1e4c1a76b51623af731a4f2299 Mon Sep 17 00:00:00 2001 From: Grzegorz <19194188+farce1@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:34:31 +0200 Subject: [PATCH] fix(security): prevent XSS in library search snippets --- src/components/library/SearchResultRow.tsx | 49 +++++++++++++++++++--- src/hooks/useLibrary.ts | 5 ++- src/lib/searchSnippet.ts | 2 + src/views/LibraryView.tsx | 5 --- 4 files changed, 48 insertions(+), 13 deletions(-) create mode 100644 src/lib/searchSnippet.ts diff --git a/src/components/library/SearchResultRow.tsx b/src/components/library/SearchResultRow.tsx index f327739..9df7cbd 100644 --- a/src/components/library/SearchResultRow.tsx +++ b/src/components/library/SearchResultRow.tsx @@ -1,6 +1,8 @@ import { AudioLines, Mic, Monitor } from 'lucide-react'; +import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; +import { SEARCH_SNIPPET_HIGHLIGHT_END, SEARCH_SNIPPET_HIGHLIGHT_START } from '../../lib/searchSnippet'; import type { SearchResult } from '../../types'; import { formatDate, formatDuration, normalizeAudioSource, statusClasses } from './meetingUtils'; @@ -10,7 +12,6 @@ type SearchResultRowProps = { selectionMode: boolean; onOpen: (id: number) => void; onSelect: (id: number) => void; - renderSnippet: (html: string) => { __html: string }; }; function sourceIcon(audioSource: string | null) { @@ -22,13 +23,50 @@ function sourceIcon(audioSource: string | null) { return null; } + +function renderSnippet(snippet: string): ReactNode[] { + const nodes: ReactNode[] = []; + let remaining = snippet; + let keyIndex = 0; + + while (remaining.length > 0) { + const start = remaining.indexOf(SEARCH_SNIPPET_HIGHLIGHT_START); + + if (start === -1) { + nodes.push(remaining); + break; + } + + if (start > 0) { + nodes.push(remaining.slice(0, start)); + } + + remaining = remaining.slice(start + SEARCH_SNIPPET_HIGHLIGHT_START.length); + const end = remaining.indexOf(SEARCH_SNIPPET_HIGHLIGHT_END); + + if (end === -1) { + nodes.push(remaining); + break; + } + + nodes.push( + + {remaining.slice(0, end)} + , + ); + + remaining = remaining.slice(end + SEARCH_SNIPPET_HIGHLIGHT_END.length); + } + + return nodes; +} + export function SearchResultRow({ result, selected, selectionMode, onOpen, onSelect, - renderSnippet, }: SearchResultRowProps) { const { t } = useTranslation('library'); @@ -70,10 +108,9 @@ export function SearchResultRow({
{formatDate(result.started_at)} · {formatDuration(result.duration_seconds)}
- ++ {renderSnippet(result.snippet)} +
diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts index 04a3dc0..94dc3b9 100644 --- a/src/hooks/useLibrary.ts +++ b/src/hooks/useLibrary.ts @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { getDb } from '../lib/db'; import { buildMeetingFilterParams } from '../lib/libraryFilterParams'; +import { SEARCH_SNIPPET_HIGHLIGHT_END, SEARCH_SNIPPET_HIGHLIGHT_START } from '../lib/searchSnippet'; import type { DateSection, LibraryFilters, @@ -247,14 +248,14 @@ export function useLibrary(options: UseLibraryOptions = {}) { m.status, m.duration_seconds, m.audio_sources, - snippet(meetings_fts, 1, '', '', '...', 32) AS snippet + snippet(meetings_fts, 1, $2, $3, '...', 32) AS snippet FROM meetings_fts JOIN meetings m ON m.id = meetings_fts.rowid WHERE meetings_fts MATCH $1 AND m.deleted_at IS NULL ORDER BY bm25(meetings_fts) LIMIT 50`, - [sanitizeFtsQuery(cleaned)], + [sanitizeFtsQuery(cleaned), SEARCH_SNIPPET_HIGHLIGHT_START, SEARCH_SNIPPET_HIGHLIGHT_END], ); setSearchResults(rows); diff --git a/src/lib/searchSnippet.ts b/src/lib/searchSnippet.ts new file mode 100644 index 0000000..b86de32 --- /dev/null +++ b/src/lib/searchSnippet.ts @@ -0,0 +1,2 @@ +export const SEARCH_SNIPPET_HIGHLIGHT_START = '__OPENNOTES_HIGHLIGHT_START__'; +export const SEARCH_SNIPPET_HIGHLIGHT_END = '__OPENNOTES_HIGHLIGHT_END__'; diff --git a/src/views/LibraryView.tsx b/src/views/LibraryView.tsx index 10af803..7d8874d 100644 --- a/src/views/LibraryView.tsx +++ b/src/views/LibraryView.tsx @@ -14,10 +14,6 @@ import { useLibrary } from '../hooks/useLibrary'; import { bulkExportZip, exportMeeting, type ExportFormat } from '../lib/export'; import type { MeetingWithPreview, SortDirection, SortField, ViewMode } from '../types'; -function renderSearchSnippet(html: string): { __html: string } { - return { __html: html }; -} - function SkeletonLine({ className }: { className: string }) { return ; } @@ -387,7 +383,6 @@ export function LibraryView({ scope = 'library' }: LibraryViewProps) { selectionMode={selectionEnabled ? isSelectionMode : false} onOpen={onOpenMeeting} onSelect={selectionEnabled ? toggleSelect : () => undefined} - renderSnippet={renderSearchSnippet} /> ))}