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:
@@ -381,7 +381,7 @@ function selectGap(gapId) {
|
||||
|
||||
document.getElementById('detailPanel').classList.remove('hidden');
|
||||
document.getElementById('detailTitle').innerHTML =
|
||||
`<span class="severity-${gap.severity}">GAP:</span> ${gap.topic}`;
|
||||
`<span class="severity-${escapeHtml(gap.severity)}">GAP:</span> ${escapeHtml(gap.topic)}`;
|
||||
|
||||
// Components in same layer (what exists nearby)
|
||||
const nearby = COMPONENTS.filter(c => c.layer === gap.layer);
|
||||
@@ -409,10 +409,10 @@ function selectGap(gapId) {
|
||||
</div>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1">What's Missing</div>
|
||||
<div class="text-xs text-slate-300 leading-relaxed mb-3">${gap.description}</div>
|
||||
<div class="text-xs text-slate-300 leading-relaxed mb-3">${escapeHtml(gap.description)}</div>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1">Evidence</div>
|
||||
<div class="text-xs text-slate-400 leading-relaxed mb-3 p-2 rounded bg-slate-800/60 border border-slate-700/30">${gap.evidence || 'No evidence recorded'}</div>
|
||||
<div class="text-xs text-slate-400 leading-relaxed mb-3 p-2 rounded bg-slate-800/60 border border-slate-700/30">${escapeHtml(gap.evidence || 'No evidence recorded')}</div>
|
||||
|
||||
<div class="text-xs font-semibold text-slate-400 mb-1">Priority</div>
|
||||
<div class="text-xs text-slate-300 mb-3">${sevLabel[gap.severity] || gap.severity}</div>
|
||||
|
||||
@@ -197,7 +197,7 @@ function synthesizeAnswer() {
|
||||
<h2 class="text-lg font-semibold text-white">AI Answer</h2>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-900/30 text-blue-400 border border-blue-800/30">just generated</span>
|
||||
</div>
|
||||
<div class="text-slate-300 text-sm leading-relaxed whitespace-pre-line">${data.answer}</div>
|
||||
<div class="text-slate-300 text-sm leading-relaxed whitespace-pre-line">${escapeHtml(data.answer)}</div>
|
||||
</div>`;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -425,8 +425,8 @@ const network = {{ network | tojson }};
|
||||
const moreCount = (d.drafts || []).length > 5 ? `<div class="text-slate-500 mt-1">+${d.drafts.length - 5} more</div>` : '';
|
||||
|
||||
tooltip.innerHTML = `
|
||||
<div class="font-semibold text-white mb-1">${d.name}</div>
|
||||
<div class="text-slate-400 text-xs mb-2">${d.org || 'Unknown org'}</div>
|
||||
<div class="font-semibold text-white mb-1">${escapeHtml(d.name)}</div>
|
||||
<div class="text-slate-400 text-xs mb-2">${escapeHtml(d.org || 'Unknown org')}</div>
|
||||
<div class="flex gap-4 text-xs mb-2">
|
||||
<span><span class="text-blue-400 font-medium">${d.draft_count}</span> drafts</span>
|
||||
<span>avg <span class="text-emerald-400 font-medium">${d.avg_score}</span></span>
|
||||
|
||||
@@ -5,8 +5,23 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}IETF Draft Analyzer{% endblock %}</title>
|
||||
<script src="/static/js/tailwind.js"></script>
|
||||
<script src="/static/js/purify.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/fonts.css">
|
||||
<script>
|
||||
// Global HTML escape helper for safe innerHTML usage
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
// Safe markdown rendering: marked + DOMPurify
|
||||
function safeMarkdown(md) {
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
return DOMPurify.sanitize(marked.parse(md || ''));
|
||||
}
|
||||
return escapeHtml(md || '');
|
||||
}
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
|
||||
@@ -135,10 +135,11 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="/static/js/marked.min.js"></script>
|
||||
<script src="/static/js/purify.min.js"></script>
|
||||
<script>
|
||||
const rawMd = {{ draft.content_md | tojson }};
|
||||
document.getElementById('renderedView').innerHTML = marked.parse(rawMd);
|
||||
document.getElementById('renderedView').innerHTML = DOMPurify.sanitize(marked.parse(rawMd));
|
||||
|
||||
function toggleView(view) {
|
||||
const rendered = document.getElementById('renderedView');
|
||||
|
||||
@@ -135,7 +135,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="/static/js/marked.min.js"></script>
|
||||
<script src="/static/js/purify.min.js"></script>
|
||||
<script>
|
||||
let rawMarkdown = '';
|
||||
|
||||
@@ -186,7 +187,7 @@ document.getElementById('generateForm').addEventListener('submit', async (e) =>
|
||||
tagsList.appendChild(span);
|
||||
});
|
||||
|
||||
document.getElementById('markdownPreview').innerHTML = marked.parse(data.content);
|
||||
document.getElementById('markdownPreview').innerHTML = DOMPurify.sanitize(marked.parse(data.content));
|
||||
document.getElementById('previewLoading').classList.add('hidden');
|
||||
document.getElementById('previewContent').classList.remove('hidden');
|
||||
document.getElementById('previewActions').classList.remove('hidden');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="/static/js/d3.v7.min.js"></script>
|
||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
<script src="/static/js/plotly.min.js"></script>
|
||||
<style>
|
||||
#citationSvg {
|
||||
width: 100%;
|
||||
@@ -462,7 +462,7 @@ document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">${i + 1}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<a href="https://www.rfc-editor.org/rfc/rfc${parseInt(rfc.ref_id)}" target="_blank" rel="noopener"
|
||||
class="text-orange-400 hover:text-orange-300 transition font-medium text-sm">${rfc.title}</a>
|
||||
class="text-orange-400 hover:text-orange-300 transition font-medium text-sm">${escapeHtml(rfc.title)}</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium
|
||||
@@ -540,9 +540,9 @@ document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
|
||||
node.on('mouseover', function(event, d) {
|
||||
const typeLabel = d.type === 'rfc' ? 'RFC' : d.type === 'bcp' ? 'BCP' : 'Draft';
|
||||
const catLine = d.category ? `<div class="text-slate-500 text-xs mb-1">${d.category}</div>` : '';
|
||||
const catLine = d.category ? `<div class="text-slate-500 text-xs mb-1">${escapeHtml(d.category)}</div>` : '';
|
||||
tooltip.innerHTML = `
|
||||
<div class="font-semibold text-white mb-1">${d.title}</div>
|
||||
<div class="font-semibold text-white mb-1">${escapeHtml(d.title)}</div>
|
||||
${catLine}
|
||||
<div class="flex gap-4 text-xs">
|
||||
<span class="text-slate-400">${typeLabel}</span>
|
||||
|
||||
@@ -211,12 +211,12 @@ async function runComparison() {
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
result.innerHTML = '<div class="text-red-400">Error: ' + data.error + '</div>';
|
||||
result.innerHTML = '<div class="text-red-400">Error: ' + escapeHtml(data.error) + '</div>';
|
||||
} else {
|
||||
result.innerHTML = '<div class="text-slate-300 whitespace-pre-line leading-relaxed">' + data.text + '</div>';
|
||||
result.innerHTML = '<div class="text-slate-300 whitespace-pre-line leading-relaxed">' + escapeHtml(data.text) + '</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
result.innerHTML = '<div class="text-red-400">Error: ' + e.message + '</div>';
|
||||
result.innerHTML = '<div class="text-red-400">Error: ' + escapeHtml(e.message) + '</div>';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Regenerate';
|
||||
|
||||
@@ -440,8 +440,8 @@ function renderTags(tags) {
|
||||
const container = document.getElementById('tagContainer');
|
||||
container.innerHTML = tags.map(t =>
|
||||
`<span class="tag-chip inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
|
||||
${t}
|
||||
<button onclick="removeTag('${t}')" class="hover:text-red-400 transition ml-0.5">×</button>
|
||||
${escapeHtml(t)}
|
||||
<button onclick="removeTag('${escapeHtml(t)}')" class="hover:text-red-400 transition ml-0.5">×</button>
|
||||
</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
<h1 class="text-2xl font-bold text-white">Dashboard Overview</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">IETF AI/Agent Internet-Drafts at a glance. Drafts are fetched from the IETF Datatracker, then analyzed by Claude AI across five dimensions (novelty, maturity, overlap, momentum, relevance) to produce a composite score from 1.0 to 5.0.</p>
|
||||
</div>
|
||||
{% if is_admin %}
|
||||
<a href="/export/obsidian"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600/80 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition-colors flex-shrink-0"
|
||||
title="Download all research data as an Obsidian vault with interlinked notes, Mermaid charts, and YAML frontmatter">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
Download for Obsidian
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Stat cards -->
|
||||
|
||||
@@ -383,7 +383,7 @@ function runGenerate() {
|
||||
|
||||
data.proposals.forEach((p, i) => {
|
||||
const gapPills = (p.gap_ids || []).map(gid =>
|
||||
`<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">#${gid}</span>`
|
||||
`<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">#${parseInt(gid)}</span>`
|
||||
).join(' ');
|
||||
|
||||
const card = document.createElement('div');
|
||||
@@ -393,14 +393,14 @@ function runGenerate() {
|
||||
card.onclick = () => fillFormFromProposal(i);
|
||||
card.innerHTML = `
|
||||
<div class="flex items-start justify-between gap-3 mb-1.5">
|
||||
<h3 class="text-sm font-semibold text-white">${p.title}</h3>
|
||||
<h3 class="text-sm font-semibold text-white">${escapeHtml(p.title)}</h3>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-green-500/20 text-green-400 ring-1 ring-green-500/30 whitespace-nowrap">SAVED</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-2">${p.description || ''}</p>
|
||||
<p class="text-xs text-slate-400 mb-2">${escapeHtml(p.description || '')}</p>
|
||||
<div class="flex items-center gap-3 flex-wrap text-[10px]">
|
||||
<span class="text-slate-500">Gaps: ${gapPills || '<span class="text-slate-600">none</span>'}</span>
|
||||
${p.intended_wg ? `<span class="text-slate-500">WG: <span class="text-slate-400">${p.intended_wg}</span></span>` : ''}
|
||||
${p.draft_name ? `<span class="text-slate-500 font-mono">${p.draft_name}</span>` : ''}
|
||||
${p.intended_wg ? `<span class="text-slate-500">WG: <span class="text-slate-400">${escapeHtml(p.intended_wg)}</span></span>` : ''}
|
||||
${p.draft_name ? `<span class="text-slate-500 font-mono">${escapeHtml(p.draft_name)}</span>` : ''}
|
||||
</div>
|
||||
<p class="text-[10px] text-blue-400/60 mt-2">Click to load into editor below ↓</p>
|
||||
`;
|
||||
|
||||
@@ -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>'}
|
||||
|
||||
@@ -197,13 +197,13 @@ document.getElementById('scatter').on('plotly_click', function(data) {
|
||||
|
||||
top20.forEach((d, i) => {
|
||||
const shortName = d.name.replace('draft-', '').substring(0, 40);
|
||||
const sourceBadge = d.source !== 'ietf' ? ` <span style="display:inline-block;padding:1px 5px;border-radius:4px;font-size:0.55rem;font-weight:600;background:rgba(168,85,247,0.15);color:#c084fc;border:1px solid rgba(168,85,247,0.3);vertical-align:middle">${d.source.toUpperCase()}</span>` : '';
|
||||
const sourceBadge = d.source !== 'ietf' ? ` <span style="display:inline-block;padding:1px 5px;border-radius:4px;font-size:0.55rem;font-weight:600;background:rgba(168,85,247,0.15);color:#c084fc;border:1px solid rgba(168,85,247,0.3);vertical-align:middle">${escapeHtml(d.source.toUpperCase())}</span>` : '';
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-slate-800/50 transition';
|
||||
row.innerHTML = `
|
||||
<td class="px-4 py-3 text-slate-500 font-mono text-xs">${i + 1}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="/drafts/${d.name}" class="text-blue-400 hover:text-blue-300 transition text-xs font-mono">${shortName}</a>${sourceBadge}
|
||||
<a href="/drafts/${encodeURIComponent(d.name)}" class="text-blue-400 hover:text-blue-300 transition text-xs font-mono">${escapeHtml(shortName)}</a>${sourceBadge}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="score-badge ${scoreClass(d.score)}">${d.score.toFixed(2)}</span>
|
||||
@@ -214,7 +214,7 @@ document.getElementById('scatter').on('plotly_click', function(data) {
|
||||
<td class="px-4 py-3 text-center">${dimBadge(d.momentum)}</td>
|
||||
<td class="px-4 py-3 text-center">${dimBadge(d.overlap, true)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">${d.category}</span>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">${escapeHtml(d.category)}</span>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
|
||||
Reference in New Issue
Block a user