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