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

@@ -12,10 +12,13 @@ Usage:
from __future__ import annotations
import hashlib
import ipaddress
import json
import re
import socket
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from dotenv import load_dotenv
# Load .env from project root (same pattern as analyzer.py)
@@ -108,8 +111,26 @@ class ProposalIntake:
)
raise SystemExit(1)
@staticmethod
def _is_private_url(url: str) -> bool:
"""Block requests to private/loopback IP ranges (SSRF protection)."""
try:
hostname = urlparse(url).hostname
if not hostname:
return True
addr = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
for _, _, _, _, sockaddr in addr:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return True
except (socket.gaierror, ValueError, OSError):
return True
return False
def fetch_url(self, url: str) -> str:
"""Fetch a URL and return its text content (best-effort)."""
if self._is_private_url(url):
return f"[Blocked: private/internal URL {url}]"
try:
resp = httpx.get(url, follow_redirects=True, timeout=30,
headers={"User-Agent": "IETF-Draft-Analyzer/1.0"})

View File

@@ -67,7 +67,10 @@ def create_app(dev: bool = False) -> Flask:
g.start_time = time.time()
@application.after_request
def log_request(response):
def set_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
if hasattr(g, "start_time"):
duration = (time.time() - g.start_time) * 1000
logger = logging.getLogger("webui")

6
src/webui/static/js/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
src/webui/static/js/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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>`;
}
})

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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');

View File

@@ -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');

View File

@@ -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>

View File

@@ -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';

View File

@@ -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">&times;</button>
${escapeHtml(t)}
<button onclick="removeTag('${escapeHtml(t)}')" class="hover:text-red-400 transition ml-0.5">&times;</button>
</span>`
).join('');
}

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>

View File

@@ -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 -->

View File

@@ -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 &darr;</p>
`;

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>'}

View File

@@ -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);