feat(util): htmlSanitize — whitelist-based DOM scrubber for PostModal's description v-html (Patreon ships HTML; sanitize before render)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
// Whitelist-based HTML sanitizer for rendering third-party post
|
||||
// descriptions (e.g. Patreon) via v-html. Strips script/style/iframe
|
||||
// + event handlers + dangerous hrefs. Tag whitelist covers what
|
||||
// Patreon, SubscribeStar, and similar platforms ship in normal posts.
|
||||
//
|
||||
// Not a substitute for server-side sanitization in higher-stakes
|
||||
// contexts. This is for FC's single-operator homelab posture where
|
||||
// the content source is the operator's own subscriptions.
|
||||
|
||||
const ALLOWED_TAGS = new Set([
|
||||
'a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li',
|
||||
'ol', 'p', 'pre', 's', 'span', 'strong', 'sub', 'sup', 'u', 'ul',
|
||||
])
|
||||
|
||||
const ALLOWED_ATTRS = {
|
||||
a: new Set(['href', 'title', 'rel', 'target']),
|
||||
img: new Set(['src', 'alt', 'title', 'width', 'height']),
|
||||
// any tag → these always allowed
|
||||
'*': new Set(['class']),
|
||||
}
|
||||
|
||||
const SAFE_URL_RE = /^(https?:|mailto:|#|\/)/i
|
||||
|
||||
export function sanitizeHtml (html) {
|
||||
if (typeof html !== 'string' || !html) return ''
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
_scrubNode(doc.body)
|
||||
return doc.body.innerHTML
|
||||
}
|
||||
|
||||
function _scrubNode (node) {
|
||||
const children = Array.from(node.children)
|
||||
for (const child of children) {
|
||||
const tag = child.tagName.toLowerCase()
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
// Strip the tag but keep its text content as a fallback.
|
||||
const text = document.createTextNode(child.textContent || '')
|
||||
child.replaceWith(text)
|
||||
continue
|
||||
}
|
||||
const allowed = new Set([
|
||||
...(ALLOWED_ATTRS[tag] || []),
|
||||
...(ALLOWED_ATTRS['*'] || []),
|
||||
])
|
||||
for (const attr of Array.from(child.attributes)) {
|
||||
const name = attr.name.toLowerCase()
|
||||
if (name.startsWith('on') || !allowed.has(name)) {
|
||||
child.removeAttribute(attr.name)
|
||||
continue
|
||||
}
|
||||
if ((name === 'href' || name === 'src') && !SAFE_URL_RE.test(attr.value)) {
|
||||
child.removeAttribute(attr.name)
|
||||
}
|
||||
}
|
||||
if (tag === 'a' && child.getAttribute('target') === '_blank') {
|
||||
child.setAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
_scrubNode(child)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user