From 28a22f2a59239df9ea6efd7430a9491b960471c5 Mon Sep 17 00:00:00 2001 From: Gabe Hamilton Date: Wed, 11 Mar 2026 16:55:35 -0600 Subject: [PATCH] fix(security): replace regex HTML sanitizer with DOMPurify to prevent XSS (#510) * fix(security): replace regex HTML sanitizer with DOMPurify to prevent XSS The previous sanitizeRenderedHtml() used regex patterns to strip dangerous HTML tags and event handler attributes before assigning to innerHTML. Regex- based HTML sanitization is notoriously bypassable via: - SVG/MathML elements not in the blocklist () - Newline-split event handlers () - Mutation XSS (browser parsing quirks that reconstruct dangerous DOM) - Encoded attribute values and alternative quote styles - Nested/recursive tag patterns that defeat linear regex This is exploitable through prompt injection: if an LLM tool output contains crafted HTML, it flows through marked.parse() -> sanitizeRenderedHtml() -> innerHTML, allowing script execution in the user's browser session. Replace the regex sanitizer with DOMPurify 3.2.3, the industry-standard DOM-based HTML sanitizer. DOMPurify parses HTML into a real DOM tree and walks it node-by-node, which eliminates all known bypass vectors. It is used by Mozilla, Google, and most major web applications. CDN: cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js SRI: sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl Audited all 60+ innerHTML assignments in app.js: - 5 use renderMarkdown() -> now protected by DOMPurify - Remainder use escapeHtml(), static literals, or empty strings Co-Authored-By: Claude Sonnet 4.6 * fix(security): guard sanitizeRenderedHtml against DOMPurify CDN unavailability [skip-regression-check] Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- src/channels/web/static/app.js | 32 ++++++++++++------------------ src/channels/web/static/index.html | 5 +++++ 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 7ca9a25b..de1f83b6 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -676,26 +676,20 @@ function renderMarkdown(text) { return escapeHtml(text); } -// Strip dangerous HTML elements and attributes from rendered markdown. -// This prevents XSS from tool output or prompt injection in LLM responses. +// Sanitize rendered HTML using DOMPurify to prevent XSS from tool output +// or prompt injection in LLM responses. DOMPurify is a DOM-based sanitizer +// that handles all known bypass vectors (SVG onload, newline-split event +// handlers, mutation XSS, etc.) unlike the regex approach it replaces. function sanitizeRenderedHtml(html) { - html = html.replace(/)<[^<]*)*<\/script>/gi, ''); - html = html.replace(/]*>[\s\S]*?<\/iframe>/gi, ''); - html = html.replace(/]*>[\s\S]*?<\/object>/gi, ''); - html = html.replace(/]*\/?>/gi, ''); - html = html.replace(/]*>[\s\S]*?<\/form>/gi, ''); - html = html.replace(/]*>[\s\S]*?<\/style>/gi, ''); - html = html.replace(/]*\/?>/gi, ''); - html = html.replace(/]*\/?>/gi, ''); - html = html.replace(/]*\/?>/gi, ''); - // Remove event handler attributes (onclick, onerror, onload, etc.) - html = html.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, ''); - html = html.replace(/\s+on\w+\s*=\s*'[^']*'/gi, ''); - html = html.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, ''); - // Remove javascript: and data: URLs in href/src attributes - html = html.replace(/(href|src|action)\s*=\s*["']?\s*javascript\s*:/gi, '$1="'); - html = html.replace(/(href|src|action)\s*=\s*["']?\s*data\s*:/gi, '$1="'); - return html; + if (typeof DOMPurify !== 'undefined') { + return DOMPurify.sanitize(html, { + USE_PROFILES: { html: true }, + FORBID_TAGS: ['style', 'script'], + FORBID_ATTR: ['style', 'onerror', 'onload'] + }); + } + // DOMPurify not available (CDN unreachable) — return empty string rather than unsanitized HTML + return ''; } function copyCodeBlock(btn) { diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 6f21b428..b6dd9d3a 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -15,6 +15,11 @@ +