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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user