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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user