fix(ui): sanitize Meilisearch HTML output, use event delegation, improve error messages

Address code review feedback:
- Sanitize highlighted HTML from Meilisearch to prevent XSS (only allow
  <mark> tags, escape everything else)
- Replace inline onclick handlers with event delegation for pagination
- Improve error message to be more user-friendly with technical detail
  in smaller text

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-26 09:34:31 +00:00
parent e9dd3ef1c2
commit 483a5b71a1
+32 -7
View File
@@ -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 = '<div style="text-align:center;padding:2rem;color:#dc2626;"><i class="fas fa-exclamation-triangle fa-2x"></i><p style="margin-top:0.5rem;">Search unavailable: ' + err.message + '</p></div>';
resultsDiv.innerHTML = '<div style="text-align:center;padding:2rem;color:#dc2626;"><i class="fas fa-exclamation-triangle fa-2x"></i><p style="margin-top:0.5rem;">Search is temporarily unavailable. Please try again in a moment.</p><p style="font-size:0.75rem;color:#9ca3af;margin-top:0.25rem;">' + escapeHtml(err.message) + '</p></div>';
});
}
@@ -157,6 +157,24 @@
return d.innerHTML;
}
/**
* Sanitize Meilisearch highlighted HTML: allow only <mark> tags,
* escape everything else to prevent XSS from indexed content.
*/
function sanitizeHighlight(html) {
// Temporarily replace <mark> and </mark> with placeholders
var safe = html
.replace(/<mark>/gi, '\x00MARK_OPEN\x00')
.replace(/<\/mark>/gi, '\x00MARK_CLOSE\x00');
// Escape all remaining HTML
safe = escapeHtml(safe);
// Restore the <mark> tags
safe = safe
.replace(/\x00MARK_OPEN\x00/g, '<mark>')
.replace(/\x00MARK_CLOSE\x00/g, '</mark>');
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 <mark> tags; we trust it here
// because it comes from our own Meilisearch instance.
var trimmed = snippet.length > 500 ? snippet.substring(0, 500) + '…' : snippet;
snippetHtml = '<div class="search-result-snippet">…' + trimmed + '…</div>';
snippetHtml = '<div class="search-result-snippet">…' + sanitizeHighlight(trimmed) + '…</div>';
}
// Sanitize title (may contain <mark> highlights from _formatted)
var safeTitle = (fmt.document_title) ? sanitizeHighlight(title) : escapeHtml(title);
return '<div class="search-result">' +
'<div class="search-result-title"><a href="' + escapeHtml(fileUrl) + '">' + title + '</a></div>' +
'<div class="search-result-title"><a href="' + escapeHtml(fileUrl) + '">' + safeTitle + '</a></div>' +
'<div class="search-result-url">' + escapeHtml(filename) + '</div>' +
(badges ? '<div class="search-result-meta">' + badges + '</div>' : '') +
snippetHtml +
@@ -206,9 +225,9 @@
// Pagination
if (pages > 1) {
var btns = [];
if (page > 1) btns.push('<button onclick="doSearch(' + (page - 1) + ')">« Previous</button>');
if (page > 1) btns.push('<button data-page="' + (page - 1) + '">« Previous</button>');
btns.push('<span>Page ' + page + ' of ' + pages + '</span>');
if (page < pages) btns.push('<button onclick="doSearch(' + (page + 1) + ')">Next »</button>');
if (page < pages) btns.push('<button data-page="' + (page + 1) + '">Next »</button>');
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) {