fix: security hardening — self-hosted JS, XSS protection, SSRF blocking

- Replace all CDN script tags (marked, plotly) with self-hosted static files
- Add DOMPurify for sanitizing markdown-rendered HTML
- Add escapeHtml() helper to base.html for all innerHTML operations
- Sanitize dynamic data in innerHTML across 13 templates
- Add security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
- Add SSRF protection to proposal intake URL fetcher (block private/loopback IPs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 04:47:32 +01:00
parent d1a20fa02e
commit f8ed2b83e9
18 changed files with 94 additions and 42 deletions

View File

@@ -150,7 +150,7 @@ function runIntake() {
data.proposals.forEach((p, i) => {
const gapPills = (p.gap_ids || []).map(gid =>
`<a href="/gaps/${gid}" class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 hover:text-blue-400 transition">#${gid}</a>`
`<a href="/gaps/${parseInt(gid)}" class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 hover:text-blue-400 transition">#${parseInt(gid)}</a>`
).join(' ');
const card = document.createElement('div');
@@ -158,10 +158,10 @@ function runIntake() {
card.style.animationDelay = `${i * 0.1}s`;
card.innerHTML = `
<div class="flex items-start justify-between gap-3 mb-2">
<a href="/proposals/${p.id}" class="text-base font-semibold text-white hover:text-blue-400 transition">${p.title}</a>
<a href="/proposals/${parseInt(p.id)}" class="text-base font-semibold text-white hover:text-blue-400 transition">${escapeHtml(p.title)}</a>
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30 whitespace-nowrap">IDEA</span>
</div>
<p class="text-sm text-slate-400 mb-3">${p.description}</p>
<p class="text-sm text-slate-400 mb-3">${escapeHtml(p.description)}</p>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-[10px] text-slate-500">Gaps:</span>
${gapPills || '<span class="text-[10px] text-slate-600">none linked</span>'}