| 1 | /**
|
|---|
| 2 | * HtmlSanitizerService — clean user-authored HTML from the WYSIWYG editor
|
|---|
| 3 | * before storing it in the DB.
|
|---|
| 4 | *
|
|---|
| 5 | * Pipeline order on the render side (posts.js):
|
|---|
| 6 | * 1. content already sanitized HTML (this service ran on save)
|
|---|
| 7 | * 2. autoembed → adds iframes for Spotify/YouTube/etc (server-controlled, safe)
|
|---|
| 8 | * 3. shortcode replacement → adds custom embed HTML (server-controlled, safe)
|
|---|
| 9 | *
|
|---|
| 10 | * Shortcodes like [[track:UUID]] / [[album:Name]] / [[playlist:slug]] live in
|
|---|
| 11 | * text nodes — sanitize-html preserves text, so they pass through untouched.
|
|---|
| 12 | */
|
|---|
| 13 |
|
|---|
| 14 | import sanitizeHtml from 'sanitize-html';
|
|---|
| 15 |
|
|---|
| 16 | const ALLOWED_TAGS = [
|
|---|
| 17 | // Block
|
|---|
| 18 | 'p', 'div', 'br', 'hr',
|
|---|
| 19 | 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|---|
| 20 | 'blockquote', 'pre',
|
|---|
| 21 | 'ul', 'ol', 'li',
|
|---|
| 22 | 'figure', 'figcaption',
|
|---|
| 23 | 'table', 'thead', 'tbody', 'tr', 'td', 'th',
|
|---|
| 24 | // Inline
|
|---|
| 25 | 'strong', 'em', 'b', 'i', 'u', 's', 'mark', 'small', 'sub', 'sup',
|
|---|
| 26 | 'code', 'a', 'span', 'img',
|
|---|
| 27 | // Native media (bare .webm/.mp4/.mp3 embeds + federated-in players)
|
|---|
| 28 | 'video', 'audio', 'source',
|
|---|
| 29 | ];
|
|---|
| 30 |
|
|---|
| 31 | // Per-tag attribute allowlist. '*' applies to every tag.
|
|---|
| 32 | const ALLOWED_ATTRS = {
|
|---|
| 33 | '*': ['class', 'id', 'dir', 'lang', 'data-sc'],
|
|---|
| 34 | a: ['href', 'title', 'target', 'rel'],
|
|---|
| 35 | img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
|
|---|
| 36 | video: ['src', 'controls', 'preload', 'poster', 'width', 'height', 'loop', 'muted', 'autoplay', 'playsinline'],
|
|---|
| 37 | audio: ['src', 'controls', 'preload', 'loop', 'muted', 'autoplay'],
|
|---|
| 38 | source: ['src', 'type'],
|
|---|
| 39 | };
|
|---|
| 40 |
|
|---|
| 41 | const ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel'];
|
|---|
| 42 | const ALLOWED_SCHEMES_BY_TAG = {
|
|---|
| 43 | img: ['http', 'https', 'data'],
|
|---|
| 44 | a: ['http', 'https', 'mailto', 'tel'],
|
|---|
| 45 | video: ['http', 'https'],
|
|---|
| 46 | audio: ['http', 'https'],
|
|---|
| 47 | source: ['http', 'https'],
|
|---|
| 48 | };
|
|---|
| 49 |
|
|---|
| 50 | class HtmlSanitizerService {
|
|---|
| 51 | /**
|
|---|
| 52 | * Sanitize user HTML. Returns a clean string ready for DB storage.
|
|---|
| 53 | * Empty input → empty string. Anything that would have rendered as a
|
|---|
| 54 | * <script>, inline event handler, or javascript: URL is stripped.
|
|---|
| 55 | */
|
|---|
| 56 | static sanitize(html) {
|
|---|
| 57 | if (!html || typeof html !== 'string') return '';
|
|---|
| 58 | return sanitizeHtml(html, {
|
|---|
| 59 | allowedTags: ALLOWED_TAGS,
|
|---|
| 60 | allowedAttributes: ALLOWED_ATTRS,
|
|---|
| 61 | allowedSchemes: ALLOWED_SCHEMES,
|
|---|
| 62 | allowedSchemesByTag: ALLOWED_SCHEMES_BY_TAG,
|
|---|
| 63 | // Drop entire <script>/<style> contents (default behaviour just strips
|
|---|
| 64 | // tags and keeps inner text — we want the contents gone too).
|
|---|
| 65 | nonTextTags: ['script', 'style', 'textarea', 'noscript'],
|
|---|
| 66 | // Force external links to be safe-by-default. Server-side rewrite is
|
|---|
| 67 | // simpler than a CSP header for this case.
|
|---|
| 68 | transformTags: {
|
|---|
| 69 | a: (tagName, attribs) => {
|
|---|
| 70 | const out = { tagName, attribs: { ...attribs } };
|
|---|
| 71 | const href = (attribs.href || '').trim();
|
|---|
| 72 | if (/^https?:\/\//i.test(href)) {
|
|---|
| 73 | out.attribs.target = out.attribs.target || '_blank';
|
|---|
| 74 | out.attribs.rel = 'noopener noreferrer';
|
|---|
| 75 | }
|
|---|
| 76 | return out;
|
|---|
| 77 | },
|
|---|
| 78 | },
|
|---|
| 79 | });
|
|---|
| 80 | }
|
|---|
| 81 |
|
|---|
| 82 | /**
|
|---|
| 83 | * Plain-text extract for excerpts / search snippets. Removes ALL HTML
|
|---|
| 84 | * (not the same as sanitize — this strips everything down to text).
|
|---|
| 85 | */
|
|---|
| 86 | static toPlainText(html) {
|
|---|
| 87 | if (!html) return '';
|
|---|
| 88 | return sanitizeHtml(html, { allowedTags: [], allowedAttributes: {} })
|
|---|
| 89 | .replace(/\s+/g, ' ')
|
|---|
| 90 | .trim();
|
|---|
| 91 | }
|
|---|
| 92 | }
|
|---|
| 93 |
|
|---|
| 94 | export default HtmlSanitizerService;
|
|---|