diff --git a/frontend/templates/search.html b/frontend/templates/search.html index 18d24575..0a9f771b 100644 --- a/frontend/templates/search.html +++ b/frontend/templates/search.html @@ -147,7 +147,7 @@ .then(r => { if (!r.ok) throw new Error('Search returned ' + r.status); return r.json(); }) .then(data => renderResults(data, q)) .catch(err => { - resultsDiv.innerHTML = '

Search unavailable: ' + err.message + '

'; + resultsDiv.innerHTML = '

Search is temporarily unavailable. Please try again in a moment.

' + escapeHtml(err.message) + '

'; }); } @@ -157,6 +157,24 @@ return d.innerHTML; } + /** + * Sanitize Meilisearch highlighted HTML: allow only tags, + * escape everything else to prevent XSS from indexed content. + */ + function sanitizeHighlight(html) { + // Temporarily replace and with placeholders + var safe = html + .replace(//gi, '\x00MARK_OPEN\x00') + .replace(/<\/mark>/gi, '\x00MARK_CLOSE\x00'); + // Escape all remaining HTML + safe = escapeHtml(safe); + // Restore the tags + safe = safe + .replace(/\x00MARK_OPEN\x00/g, '') + .replace(/\x00MARK_CLOSE\x00/g, ''); + return safe; + } + function renderResults(data, q) { const { results, total, page, pages } = data; @@ -189,14 +207,15 @@ // Snippet: use highlighted text, truncate if very long var snippetHtml = ''; if (snippet) { - // The API returns highlighted HTML with tags; we trust it here - // because it comes from our own Meilisearch instance. var trimmed = snippet.length > 500 ? snippet.substring(0, 500) + '…' : snippet; - snippetHtml = '
…' + trimmed + '…
'; + snippetHtml = '
…' + sanitizeHighlight(trimmed) + '…
'; } + // Sanitize title (may contain highlights from _formatted) + var safeTitle = (fmt.document_title) ? sanitizeHighlight(title) : escapeHtml(title); + return '
' + - '' + + '' + '
' + escapeHtml(filename) + '
' + (badges ? '
' + badges + '
' : '') + snippetHtml + @@ -206,9 +225,9 @@ // Pagination if (pages > 1) { var btns = []; - if (page > 1) btns.push(''); + if (page > 1) btns.push(''); btns.push('Page ' + page + ' of ' + pages + ''); - if (page < pages) btns.push(''); + if (page < pages) btns.push(''); paginationDiv.innerHTML = btns.join(''); paginationDiv.style.display = 'flex'; } else { @@ -216,6 +235,12 @@ } } + // Event delegation for pagination buttons + paginationDiv.addEventListener('click', function(e) { + var btn = e.target.closest('button[data-page]'); + if (btn) doSearch(parseInt(btn.getAttribute('data-page'), 10)); + }); + // Event listeners searchBtn.addEventListener('click', function() { doSearch(1); }); searchInput.addEventListener('keydown', function(e) {