From 07344e084371a3c50ad9233207517f0317e9bfc8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:47:36 -0400 Subject: [PATCH] =?UTF-8?q?feat(util):=20htmlSanitize=20=E2=80=94=20whitel?= =?UTF-8?q?ist-based=20DOM=20scrubber=20for=20PostModal's=20description=20?= =?UTF-8?q?v-html=20(Patreon=20ships=20HTML;=20sanitize=20before=20render)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/utils/htmlSanitize.js | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 frontend/src/utils/htmlSanitize.js diff --git a/frontend/src/utils/htmlSanitize.js b/frontend/src/utils/htmlSanitize.js new file mode 100644 index 0000000..7978fca --- /dev/null +++ b/frontend/src/utils/htmlSanitize.js @@ -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) + } +}