Fix security vulnerabilities found in security review

- Add Content-Security-Policy meta tag restricting external resources
- Add sanitizeEvent/sanitizeTimeline to validate/allowlist data from localStorage and imported JSON
- Escape ev.thumbnail in SVG <image href> with xe() to prevent javascript: URL injection
- Escape dynamic IDs in inline onclick handlers with esc() throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Greg 2026-03-15 11:54:52 +01:00
parent c159d66eb7
commit 7497230990

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'none'; object-src 'none'; base-uri 'self';">
<title>Timelineifyer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@ -610,6 +611,45 @@ let selectedCatColor = '#4f46e5';
const PRESET_COLORS = ['#4f46e5','#e11d48','#d97706','#059669','#0284c7','#7c3aed','#ea580c','#0d9488'];
// ── Sanitization ───────────────────────────────────────────────────────────
function sanitizeEvent(ev) {
if (!ev || typeof ev !== 'object') return null;
if (typeof ev.id !== 'string' || !ev.id) return null;
if (typeof ev.date !== 'string' || !ev.date) return null;
if (typeof ev.title !== 'string' || !ev.title) return null;
const thumb = typeof ev.thumbnail === 'string' && ev.thumbnail.startsWith('data:image/') ? ev.thumbnail : '';
return {
id: ev.id,
title: ev.title,
date: ev.date,
endDate: typeof ev.endDate === 'string' ? ev.endDate : undefined,
description: typeof ev.description === 'string' ? ev.description : '',
url: typeof ev.url === 'string' ? ev.url : '',
thumbnail: thumb,
category: typeof ev.category === 'string' ? ev.category : undefined,
};
}
function sanitizeTimeline(tl) {
if (!tl || typeof tl !== 'object') return null;
if (typeof tl.id !== 'string' || !tl.id) return null;
if (typeof tl.name !== 'string' || !tl.name) return null;
if (!Array.isArray(tl.events)) return null;
return {
id: tl.id,
name: tl.name,
createdAt: typeof tl.createdAt === 'string' ? tl.createdAt : '',
dateFormat: tl.dateFormat === 'month-year' ? 'month-year' : 'full',
categories: Array.isArray(tl.categories) ? tl.categories.filter(c =>
c && typeof c === 'object' &&
typeof c.id === 'string' && c.id &&
typeof c.name === 'string' && c.name &&
typeof c.color === 'string'
).map(c => ({ id: c.id, name: c.name, color: c.color })) : [],
events: tl.events.map(sanitizeEvent).filter(Boolean),
};
}
// ── Storage ────────────────────────────────────────────────────────────────
function saveState() {
try {
@ -624,7 +664,9 @@ function loadState() {
const raw = localStorage.getItem('timelineifyer');
if (raw) {
const parsed = JSON.parse(raw);
if (parsed.timelines) state.timelines = parsed.timelines;
if (parsed.timelines && Array.isArray(parsed.timelines)) {
state.timelines = parsed.timelines.map(sanitizeTimeline).filter(Boolean);
}
}
} catch(e) { console.warn('Failed to load state', e); }
}
@ -728,11 +770,11 @@ function renderCatSection() {
const cats = tl.categories || [];
const catListHtml = cats.length
? cats.map(c => `<div class="cat-chip" style="background:${c.color}">${esc(c.name)}<button class="cat-chip-del" onclick="deleteCategory('${c.id}')" title="Delete">×</button></div>`).join('')
? cats.map(c => `<div class="cat-chip" style="background:${c.color}">${esc(c.name)}<button class="cat-chip-del" onclick="deleteCategory('${esc(c.id)}')" title="Delete">×</button></div>`).join('')
: '<span style="font-size:13px;color:var(--text-secondary)">No categories yet.</span>';
const swatchHtml = PRESET_COLORS.map(color =>
`<div class="cat-swatch${selectedCatColor === color ? ' selected' : ''}" style="background:${color}" onclick="selectCatColor('${color}')" title="${color}"></div>`
`<div class="cat-swatch${selectedCatColor === color ? ' selected' : ''}" style="background:${color}" onclick="selectCatColor('${esc(color)}')" title="${color}"></div>`
).join('');
const el = document.getElementById('cat-section');
@ -840,10 +882,10 @@ function renderList() {
<div class="timeline-card-name">${esc(tl.name)}</div>
<div class="timeline-card-meta">${count} event${count !== 1 ? 's' : ''} &nbsp;·&nbsp; ${esc(dateRange(tl))}</div>
<div class="timeline-card-actions">
<button class="btn btn-primary btn-sm" onclick="openTimeline('${tl.id}','view')">View</button>
<button class="btn btn-ghost btn-sm" onclick="openTimeline('${tl.id}','edit')">Edit</button>
<button class="btn btn-ghost btn-sm" onclick="exportTimeline('${tl.id}')">Export</button>
<button class="btn btn-danger btn-sm" onclick="deleteTimeline('${tl.id}')">Delete</button>
<button class="btn btn-primary btn-sm" onclick="openTimeline('${esc(tl.id)}','view')">View</button>
<button class="btn btn-ghost btn-sm" onclick="openTimeline('${esc(tl.id)}','edit')">Edit</button>
<button class="btn btn-ghost btn-sm" onclick="exportTimeline('${esc(tl.id)}')">Export</button>
<button class="btn btn-danger btn-sm" onclick="deleteTimeline('${esc(tl.id)}')">Delete</button>
</div>`;
grid.appendChild(card);
});
@ -907,7 +949,9 @@ function importJSON(e) {
const data = JSON.parse(ev.target.result);
if (!data.timelines || !Array.isArray(data.timelines)) throw new Error('Invalid format');
let added = 0;
data.timelines.forEach(tl => {
data.timelines.forEach(raw => {
const tl = sanitizeTimeline(raw);
if (!tl) return;
const idx = state.timelines.findIndex(t => t.id === tl.id);
if (idx >= 0) { state.timelines[idx] = tl; } else { state.timelines.push(tl); }
added++;
@ -1054,8 +1098,8 @@ function buildEventRow(ev) {
${desc}${urlBadge}
</div>
<div class="event-actions">
<button class="btn btn-ghost btn-sm" onclick="startEditEvent('${ev.id}')">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteEvent('${ev.id}')">Delete</button>
<button class="btn btn-ghost btn-sm" onclick="startEditEvent('${esc(ev.id)}')">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteEvent('${esc(ev.id)}')">Delete</button>
</div>
</div>`;
}
@ -1110,7 +1154,7 @@ function buildInlineEditForm(ev) {
</div>
<div class="form-actions">
<button class="btn btn-ghost btn-sm" onclick="cancelEditEvent()">Cancel</button>
<button class="btn btn-primary btn-sm" onclick="saveEditEvent('${ev.id}')">Save</button>
<button class="btn btn-primary btn-sm" onclick="saveEditEvent('${esc(ev.id)}')">Save</button>
</div>
</div>`;
}
@ -1465,7 +1509,7 @@ function buildSVGString(tl, events) {
let thumbSvg = '';
if (hasThumb) {
const imgEl = `<image x="${cardX}" y="${cardY}" width="${CARD_W}" height="${THUMB_H}" href="${ev.thumbnail}" preserveAspectRatio="xMidYMid slice" clip-path="url(#tc${i})"/>`;
const imgEl = `<image x="${cardX}" y="${cardY}" width="${CARD_W}" height="${THUMB_H}" href="${xe(ev.thumbnail)}" preserveAspectRatio="xMidYMid slice" clip-path="url(#tc${i})"/>`;
thumbSvg = hasUrl ? `<a href="${xe(ev.url)}" target="_blank">${imgEl}</a>` : imgEl;
}