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

@@ -301,19 +301,19 @@ if (data.empty) {
const detail = document.getElementById('linkDetail');
const simPct = (link.best_pair_sim * 100).toFixed(0);
document.getElementById('linkTitle').innerHTML =
`<span style="color:${PALETTE[link.source % PALETTE.length]}">${link.source_theme}</span>` +
`<span style="color:${PALETTE[link.source % PALETTE.length]}">${escapeHtml(link.source_theme)}</span>` +
` <span class="text-slate-500">↔</span> ` +
`<span style="color:${PALETTE[link.target % PALETTE.length]}">${link.target_theme}</span>` +
`<span style="color:${PALETTE[link.target % PALETTE.length]}">${escapeHtml(link.target_theme)}</span>` +
` <span class="text-slate-500 text-xs font-normal ml-2">${simPct}% similar</span>`;
document.getElementById('linkContent').innerHTML = `
<div class="grid grid-cols-2 gap-4">
<div class="bg-slate-900/50 rounded p-3 border border-slate-700/30">
<div class="text-slate-300 font-medium mb-1">${link.idea_a}</div>
<a href="/drafts/${link.idea_a_draft}" class="text-blue-400/70 hover:text-blue-300 text-[10px] font-mono">${link.idea_a_draft}</a>
<div class="text-slate-300 font-medium mb-1">${escapeHtml(link.idea_a)}</div>
<a href="/drafts/${encodeURIComponent(link.idea_a_draft)}" class="text-blue-400/70 hover:text-blue-300 text-[10px] font-mono">${escapeHtml(link.idea_a_draft)}</a>
</div>
<div class="bg-slate-900/50 rounded p-3 border border-slate-700/30">
<div class="text-slate-300 font-medium mb-1">${link.idea_b}</div>
<a href="/drafts/${link.idea_b_draft}" class="text-blue-400/70 hover:text-blue-300 text-[10px] font-mono">${link.idea_b_draft}</a>
<div class="text-slate-300 font-medium mb-1">${escapeHtml(link.idea_b)}</div>
<a href="/drafts/${encodeURIComponent(link.idea_b_draft)}" class="text-blue-400/70 hover:text-blue-300 text-[10px] font-mono">${escapeHtml(link.idea_b_draft)}</a>
</div>
</div>
<p class="text-slate-500 text-[10px] mt-1">These two ideas from different clusters have the strongest cross-cluster similarity.</p>
@@ -351,8 +351,8 @@ if (data.empty) {
const draftTag = idea.drafts.length > 1
? `<span class="text-slate-600">(${idea.drafts.length} drafts)</span>`
: `<span class="text-slate-600">${idea.drafts[0].replace('draft-', '').substring(0, 20)}</span>`;
return `<li class="text-xs text-slate-400 truncate" title="${idea.description || idea.title}">
<span class="text-slate-300">${idea.title}</span> ${draftTag}
return `<li class="text-xs text-slate-400 truncate" title="${escapeHtml(idea.description || idea.title)}">
<span class="text-slate-300">${escapeHtml(idea.title)}</span> ${draftTag}
</li>`;
}).join('');
const previewExtra = uniqueIdeas.length > 3
@@ -364,8 +364,8 @@ if (data.empty) {
`<a href="/drafts/${d}" class="text-blue-400/70 hover:text-blue-300 transition">${d.replace('draft-', '').substring(0, 28)}</a>`
).join(', ');
return `<div class="py-2 border-b border-slate-800/50 last:border-0">
<div class="text-xs text-slate-200 font-medium">${idea.title}</div>
${idea.description ? `<div class="text-xs text-slate-500 mt-0.5 leading-relaxed">${idea.description.substring(0, 200)}</div>` : ''}
<div class="text-xs text-slate-200 font-medium">${escapeHtml(idea.title)}</div>
${idea.description ? `<div class="text-xs text-slate-500 mt-0.5 leading-relaxed">${escapeHtml(idea.description.substring(0, 200))}</div>` : ''}
<div class="text-[10px] text-slate-600 mt-1 font-mono">${draftLinks}</div>
</div>`;
}).join('');
@@ -410,7 +410,7 @@ if (data.empty) {
card.innerHTML = `
<div class="flex items-center gap-2 mb-3">
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background: ${color}"></div>
<h3 class="text-sm font-semibold text-white truncate">${cluster.theme}</h3>
<h3 class="text-sm font-semibold text-white truncate">${escapeHtml(cluster.theme)}</h3>
${crossBadge}
<span class="ml-auto text-xs text-slate-500 flex-shrink-0">${cluster.size} ideas</span>
<svg id="chevron-${i}" class="w-4 h-4 text-slate-500 flex-shrink-0 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>