source: Klonkt/src/services/HtmlSanitizerService.js@ db81e56

main
Last change on this file since db81e56 was a85f539, checked in by Robin <roboburr@…>, 8 weeks ago

Fix: bare .webm/.mp4/.mp3 URLs render a native player

autoembed() and [[embed:]] now detect direct media-file URLs and emit a
<video>/<audio> element (was: left as a plain link). Sanitizer allows
video/audio/source with a tight attr + http(s)-scheme allowlist so
hand-authored and federated-in players survive. detectProvider() is left
untouched so the timeline/cover callers that switch on provider slugs are
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 3.3 KB
Line 
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
14import sanitizeHtml from 'sanitize-html';
15
16const 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.
32const 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
41const ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel'];
42const 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
50class 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
94export default HtmlSanitizerService;
Note: See TracBrowser for help on using the repository browser.