07344e0843
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
62 lines
2.1 KiB
JavaScript
62 lines
2.1 KiB
JavaScript
// 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)
|
|
}
|
|
}
|