/** * Sanitizer für Übungs-Rich-HTML inkl. §11 Platzhalter (span data-shinkan-exercise-media). * Restriktiver als sanitizeTrainerHtml: Allowlist für XSS-Minimierung. */ import { sanitizeInlineMediaCaption } from './inlineMediaCaption' const ALLOWED_BLOCK = new Set(['p', 'div', 'br', 'ul', 'ol', 'li', 'h3']) const ALLOWED_INLINE = new Set(['b', 'strong', 'i', 'em', 'u', 'span', 'a']) function isHttpsUrl(val) { if (!val || typeof val !== 'string') return false const s = val.trim() return s.startsWith('http://') || s.startsWith('https://') } /** Nur für unsere Embed-Markierung: erlaubt data-attribut und optionale Marker-Klasse + Größe. */ const _SIZE_OK = new Set(['small', 'medium', 'full']) function isInlineExerciseMediaPlaceholderSpan(el) { if (!el?.getAttribute || el.tagName.toLowerCase() !== 'span') return false const raw = el.getAttribute('data-shinkan-exercise-media') if (!raw || !String(raw).trim().match(/^\d+$/)) return false return true } function sanitizeAttributes(el, tagLower) { if (tagLower === 'a') { const href = el.getAttribute('href') if (href && isHttpsUrl(href)) { const out = document.createElement('a') out.setAttribute('href', href.trim()) return out.attributes } return [] } if (tagLower === 'span' && isInlineExerciseMediaPlaceholderSpan(el)) { const out = document.createElement('span') out.setAttribute('data-shinkan-exercise-media', el.getAttribute('data-shinkan-exercise-media').trim()) const sz = (el.getAttribute('data-shinkan-exercise-media-size') || '').trim().toLowerCase() if (sz && _SIZE_OK.has(sz)) { out.setAttribute('data-shinkan-exercise-media-size', sz) } const capRaw = el.getAttribute('data-shinkan-exercise-media-caption') if (capRaw != null && String(capRaw).trim()) { const cap = sanitizeInlineMediaCaption(String(capRaw)) if (cap) out.setAttribute('data-shinkan-exercise-media-caption', cap) } const cls = (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean) const keep = cls.filter((c) => c === 'shinkan-inline-media') if (keep.length) out.setAttribute('class', keep.join(' ')) return out.attributes } return [] } function cloneAsAllowed(tagLower, el) { const fresh = document.createElement(tagLower) const attrs = sanitizeAttributes(el, tagLower) for (const a of attrs) { fresh.setAttribute(a.name, a.value) } return fresh } function cleanTree(parent) { const nodes = Array.from(parent.childNodes) for (const node of nodes) { if (node.nodeType === Node.TEXT_NODE) continue if (node.nodeType === Node.COMMENT_NODE) { parent.removeChild(node) continue } if (node.nodeType !== Node.ELEMENT_NODE) { parent.removeChild(node) continue } const tag = node.tagName.toLowerCase() if (tag === 'script' || tag === 'iframe' || tag === 'object' || tag === 'embed') { parent.removeChild(node) continue } if (ALLOWED_BLOCK.has(tag) || ALLOWED_INLINE.has(tag)) { const repl = cloneAsAllowed(tag, node) while (node.firstChild) { repl.appendChild(node.firstChild) } cleanTree(repl) parent.replaceChild(repl, node) continue } while (node.firstChild) { parent.insertBefore(node.firstChild, node) } parent.removeChild(node) } } /** * @param {string|null|undefined} html * @returns {string} */ export function sanitizeExerciseRichDisplayHtml(html) { if (html == null || typeof html !== 'string') return '' const trimmed = html.trim() if (!trimmed) return '' const tpl = document.createElement('template') tpl.innerHTML = trimmed cleanTree(tpl.content) return tpl.innerHTML }