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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 01:30:58 +00:00
co-authored by Claude Opus 4.6
parent f7f8c140c3
commit 4053eb46af
3 changed files with 13 additions and 26 deletions
+2 -1
View File
@@ -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",
+8 -25
View File
@@ -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, '&quot;')}"`)
}
if (tag === 'img' && el.getAttribute('src')) {
const src = el.getAttribute('src') ?? ''
if (/^https?:\/\//i.test(src)) attrs.push(`src="${src.replace(/"/g, '&quot;')}"`)
}
const inner = [...node.childNodes].map(walk).join('')
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}</${tag}>`
}
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 */
+3
View File
@@ -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