diff --git a/static/js/documentLibrary.js b/static/js/documentLibrary.js
index da906b0..0341594 100644
--- a/static/js/documentLibrary.js
+++ b/static/js/documentLibrary.js
@@ -76,6 +76,15 @@ function _hlSearch(text) {
'$1');
} 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 _librarySelectMode = false;
let _librarySelectedIds = new Set();
@@ -2649,7 +2658,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const data = await res.json();
_researchItems = data.research || data || [];
} catch (e) {
- grid.innerHTML = `
Failed to load: ${e.message}
`;
+ grid.innerHTML = `Failed to load: ${_esc(e.message)}
`;
return;
}
_renderResearchGrid();
@@ -2691,9 +2700,9 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const sources = Array.isArray(detail.sources) ? detail.sources : [];
const sourcesList = sources.slice(0, 12).map((src, i) => {
const title = _esc(src.title || src.url || `Source ${i + 1}`);
- const url = src.url || '';
+ const url = _safeResearchHref(src.url);
return url
- ? `${title}`
+ ? `${title}`
: `${title}`;
}).join('');
const sourcesHtml = sources.length
diff --git a/static/js/research/panel.js b/static/js/research/panel.js
index 6893ec2..d515580 100644
--- a/static/js/research/panel.js
+++ b/static/js/research/panel.js
@@ -1103,8 +1103,10 @@ function _renderResult(job) {
html += '';
for (const s of job.sources.slice(0, 10)) {
const title = _esc(s.title || s.url || '');
- const url = _esc(s.url || '');
- html += `
${title}`;
+ const url = _safeSourceHref(s.url);
+ html += url
+ ? `
${title}`
+ : `
${title}`;
}
if (job.sources.length > 10) html += `
+${job.sources.length - 10} more`;
html += '
';
@@ -1231,3 +1233,11 @@ function _esc(s) {
d.textContent = s || '';
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 '';
+}
diff --git a/tests/test_research_source_link_xss.py b/tests/test_research_source_link_xss.py
new file mode 100644
index 0000000..e4cf0d8
--- /dev/null
+++ b/tests/test_research_source_link_xss.py
@@ -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