Whitelist research source links (#2499)
This commit is contained in:
@@ -76,6 +76,15 @@ function _hlSearch(text) {
|
|||||||
'<mark class="doclib-search-hl">$1</mark>');
|
'<mark class="doclib-search-hl">$1</mark>');
|
||||||
} catch { return esc; }
|
} catch { return esc; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _safeResearchHref(raw) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(String(raw || '').trim(), window.location.origin);
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href);
|
||||||
|
} catch {}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
let _libraryEscHandler = null;
|
let _libraryEscHandler = null;
|
||||||
let _librarySelectMode = false;
|
let _librarySelectMode = false;
|
||||||
let _librarySelectedIds = new Set();
|
let _librarySelectedIds = new Set();
|
||||||
@@ -2649,7 +2658,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
_researchItems = data.research || data || [];
|
_researchItems = data.research || data || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${e.message}</div>`;
|
grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${_esc(e.message)}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_renderResearchGrid();
|
_renderResearchGrid();
|
||||||
@@ -2691,9 +2700,9 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
const sources = Array.isArray(detail.sources) ? detail.sources : [];
|
const sources = Array.isArray(detail.sources) ? detail.sources : [];
|
||||||
const sourcesList = sources.slice(0, 12).map((src, i) => {
|
const sourcesList = sources.slice(0, 12).map((src, i) => {
|
||||||
const title = _esc(src.title || src.url || `Source ${i + 1}`);
|
const title = _esc(src.title || src.url || `Source ${i + 1}`);
|
||||||
const url = src.url || '';
|
const url = _safeResearchHref(src.url);
|
||||||
return url
|
return url
|
||||||
? `<li><a href="${_esc(url)}" target="_blank" rel="noopener">${title}</a></li>`
|
? `<li><a href="${url}" target="_blank" rel="noopener">${title}</a></li>`
|
||||||
: `<li>${title}</li>`;
|
: `<li>${title}</li>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const sourcesHtml = sources.length
|
const sourcesHtml = sources.length
|
||||||
|
|||||||
@@ -1103,8 +1103,10 @@ function _renderResult(job) {
|
|||||||
html += '<div class="research-job-sources">';
|
html += '<div class="research-job-sources">';
|
||||||
for (const s of job.sources.slice(0, 10)) {
|
for (const s of job.sources.slice(0, 10)) {
|
||||||
const title = _esc(s.title || s.url || '');
|
const title = _esc(s.title || s.url || '');
|
||||||
const url = _esc(s.url || '');
|
const url = _safeSourceHref(s.url);
|
||||||
html += `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>`;
|
html += url
|
||||||
|
? `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>`
|
||||||
|
: `<span class="research-source-link">${title}</span>`;
|
||||||
}
|
}
|
||||||
if (job.sources.length > 10) html += `<span class="research-source-more">+${job.sources.length - 10} more</span>`;
|
if (job.sources.length > 10) html += `<span class="research-source-more">+${job.sources.length - 10} more</span>`;
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
@@ -1231,3 +1233,11 @@ function _esc(s) {
|
|||||||
d.textContent = s || '';
|
d.textContent = s || '';
|
||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _safeSourceHref(raw) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(String(raw || '').trim(), window.location.origin);
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href);
|
||||||
|
} catch {}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|||||||
26
tests/test_research_source_link_xss.py
Normal file
26
tests/test_research_source_link_xss.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Regression guards for API-provided research source hrefs."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_library_research_preview_whitelists_source_hrefs():
|
||||||
|
src = (_REPO / "static" / "js" / "documentLibrary.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "function _safeResearchHref(raw)" in src
|
||||||
|
assert "parsed.protocol === 'http:' || parsed.protocol === 'https:'" in src
|
||||||
|
assert "const url = _safeResearchHref(src.url);" in src
|
||||||
|
assert 'href="${_esc(url)}"' not in src
|
||||||
|
assert "Failed to load: ${_esc(e.message)}" in src
|
||||||
|
assert "Failed to load: ${e.message}" not in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_panel_whitelists_source_hrefs():
|
||||||
|
src = (_REPO / "static" / "js" / "research" / "panel.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "function _safeSourceHref(raw)" in src
|
||||||
|
assert "parsed.protocol === 'http:' || parsed.protocol === 'https:'" in src
|
||||||
|
assert "const url = _safeSourceHref(s.url);" in src
|
||||||
|
assert 'const url = _esc(s.url || \'\');' not in src
|
||||||
Reference in New Issue
Block a user