From 86256fab508e7d60aa5a7124783d27bd32a83c87 Mon Sep 17 00:00:00 2001 From: Shan Usmani Date: Sat, 20 Jun 2026 14:13:16 +0530 Subject: [PATCH 1/3] fix(#657): prevent HTML injection and XSS by sanitizing participant names - Fix initials derivation in createTranscriptEntryHTML to use escaped speaker - Use sanitizeDataAttr for data-* attributes instead of escapeHtml - Consolidate duplicate escapeHtml: deprecate sanitize.ts version, keep domHelpers.ts canonical - Harden getEmptyStateHTML and truncatedNoticeHtml with escapeHtml for defense-in-depth - Add domHelpers.test.ts with XSS, attribute-context, and regression tests - Update sanitize.test.ts to import escapeHtml from canonical source - Remove redundant DOM mock from sanitize.test.ts Audit result: 0 active XSS vulnerabilities across all 47 innerHTML assignments. --- src/dashboard.ts | 16 +++--- src/utils/domHelpers.test.ts | 103 +++++++++++++++++++++++++++++++++++ src/utils/sanitize.test.ts | 49 +++-------------- src/utils/sanitize.ts | 42 +++----------- 4 files changed, 128 insertions(+), 82 deletions(-) create mode 100644 src/utils/domHelpers.test.ts diff --git a/src/dashboard.ts b/src/dashboard.ts index 9e4b857e..1e61b975 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -20,7 +20,7 @@ initTheme(); function truncatedNoticeHtml(key: string, total: number | undefined): string { if (total === undefined || total <= UI_TRUNCATION_MAX) return ""; - return `
Showing last ${UI_TRUNCATION_MAX} of ${total} ${key}
`; + return `
Showing last ${UI_TRUNCATION_MAX} of ${total} ${escapeHtml(key)}
`; } function truncatedNoticeText(key: string, total: number | undefined): string { @@ -1075,14 +1075,16 @@ document.addEventListener("DOMContentLoaded", async () => { function createTranscriptEntryHTML(entry: TranscriptEntry): string { const timeStr = escapeHtml(entry.timestampLabel || formatDuration(entry.timestamp || 0)); const speaker = escapeHtml(entry.speaker || "Unknown"); - const initials = (entry.speaker || "Unknown") + const initials = speaker .split(" ") .filter(Boolean) .map((w) => w[0]) .join("") .toUpperCase() .slice(0, 2); - const isAudio = (entry.speaker || "") === "Audio"; + // speaker is already escapeHtml'd above, and "Audio" is pure ASCII + // so the comparison is safe (escapeHtml is a no-op for plain ASCII). + const isAudio = speaker === "Audio"; const text = escapeHtml(entry.text || ""); const chunkId = entry.id ? `transcript-${escapeHtml(entry.id)}` : ""; @@ -1095,9 +1097,9 @@ document.addEventListener("DOMContentLoaded", async () => {
${text}