// 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 } // Strip all tags + decode entities to plain text. Used for titles that // arrive as stored HTML (e.g. "Edelgard at the Sex-Arcade") // which must render as text, not literal markup. DOMParser yields an inert // document (no script execution, no resource loads), so reading textContent // is safe. export function toPlainText (html) { if (typeof html !== 'string' || !html) return '' const doc = new DOMParser().parseFromString(html, 'text/html') return (doc.body.textContent || '').trim() } 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) } }