From 4053eb46afde5f0b41960511bd4b759166382dd3 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 6 Mar 2026 01:30:58 +0000 Subject: [PATCH] fix(app): replace custom HTML sanitizer with DOMPurify Install dompurify and replace the hand-rolled DOM walker sanitizer with DOMPurify.sanitize() configured with the same allowed tags. Handles mutation XSS edge cases the custom version couldn't. Co-Authored-By: Claude Opus 4.6 --- packages/app/package.json | 3 ++- packages/app/src/utils/html.ts | 33 ++++++++------------------------- pnpm-lock.yaml | 3 +++ 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 776da5e5..27066845 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -33,7 +33,8 @@ "plyr": "^3.8.4", "vue": "^3.5.29", "vue-router": "^5.0.3", - "wavesurfer.js": "^7.12.1" + "wavesurfer.js": "^7.12.1", + "dompurify": "^3.2.6" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/packages/app/src/utils/html.ts b/packages/app/src/utils/html.ts index cb6c6f5f..0fd695db 100644 --- a/packages/app/src/utils/html.ts +++ b/packages/app/src/utils/html.ts @@ -13,33 +13,16 @@ export function isSafeUrl(u: string | undefined): u is string { return !!u && typeof u === 'string' && /^https?:\/\//i.test(u.trim()) } -const ALLOWED_TAGS = new Set(['p', 'br', 'a', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'span', 'div']) -const DANGEROUS_TAGS = new Set(['script', 'style', 'iframe', 'object', 'embed']) +import DOMPurify from 'dompurify' -/** Sanitize HTML: allow safe tags, strip scripts and dangerous attributes */ +const ALLOWED_TAGS_LIST = ['p', 'br', 'a', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'span', 'div'] + +/** Sanitize HTML using DOMPurify with restricted tag set */ export function sanitizeHtml(html: string): string { - const div = document.createElement('div') - div.innerHTML = html - const walk = (node: Node): string => { - if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '' - if (node.nodeType !== Node.ELEMENT_NODE) return '' - const el = node as Element - const tag = el.tagName.toLowerCase() - if (DANGEROUS_TAGS.has(tag)) return '' - if (!ALLOWED_TAGS.has(tag)) return [...node.childNodes].map(walk).join('') - const attrs: string[] = [] - if (tag === 'a' && el.getAttribute('href')) { - const href = el.getAttribute('href') ?? '' - if (/^https?:\/\//i.test(href) && !/javascript:/i.test(href)) attrs.push(`href="${href.replace(/"/g, '"')}"`) - } - if (tag === 'img' && el.getAttribute('src')) { - const src = el.getAttribute('src') ?? '' - if (/^https?:\/\//i.test(src)) attrs.push(`src="${src.replace(/"/g, '"')}"`) - } - const inner = [...node.childNodes].map(walk).join('') - return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}` - } - return [...div.childNodes].map(walk).join('') + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ALLOWED_TAGS_LIST, + ALLOWED_ATTR: ['href', 'src', 'target', 'rel'], + }) } /** Escape HTML entities for safe rendering in a text context */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a0b3cae..34844de6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@tanstack/vue-virtual': specifier: ^3.13.19 version: 3.13.19(vue@3.5.29(typescript@5.8.3)) + dompurify: + specifier: ^3.2.6 + version: 3.3.1 hls.js: specifier: ^1.6.15 version: 1.6.15