UNPKG

tldraw

Version:

A tiny little drawing editor.

693 lines (637 loc) 15.6 kB
/*! * SVG/attribute allowlists and URI sanitization approach derived from DOMPurify. * DOMPurify — MIT License, Copyright (c) 2015 Mario Heiderich * https://github.com/cure53/DOMPurify/blob/main/LICENSE */ // --- Allowed SVG elements (lowercase) --- // Includes foreignObject, style, and animation elements. const ALLOWED_SVG_TAGS = new Set([ 'svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animate', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'feblend', 'fecolormatrix', 'fecomponenttransfer', 'fecomposite', 'feconvolvematrix', 'fediffuselighting', 'fedisplacementmap', 'fedistantlight', 'fedropshadow', 'feflood', 'fefunca', 'fefuncb', 'fefuncg', 'fefuncr', 'fegaussianblur', 'feimage', 'femerge', 'femergenode', 'femorphology', 'feoffset', 'fepointlight', 'fespecularlighting', 'fespotlight', 'fetile', 'feturbulence', 'filter', 'font', 'foreignobject', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'set', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'use', 'view', 'vkern', ]) // --- Allowed HTML elements inside foreignObject --- const ALLOWED_HTML_TAGS = new Set([ 'a', 'b', 'blockquote', 'body', 'br', 'code', 'del', 'div', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'i', 'li', 'mark', 'ol', 'p', 'pre', 'span', 'strong', 's', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul', ]) // --- Blocked HTML elements inside foreignObject --- // These are explicitly dangerous even if not in the allow list (defense in depth). const BLOCKED_HTML_TAGS = new Set([ 'script', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'link', 'meta', 'base', 'img', // onerror vector 'video', 'audio', 'source', 'picture', 'svg', // no nested SVG inside foreignObject ]) // --- Allowed SVG attributes (lowercase) --- const ALLOWED_SVG_ATTRS = new Set([ 'accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clip-path', 'clip-rule', 'clippathunits', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'diffuseconstant', 'direction', 'display', 'divisor', 'dominant-baseline', 'dur', 'dx', 'dy', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'from', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradienttransform', 'gradientunits', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'kernelmatrix', 'kernelunitlength', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'mask', 'mask-type', 'maskcontentunits', 'maskunits', 'max', 'media', 'method', 'min', 'mode', 'name', 'numoctaves', 'offset', 'opacity', 'operator', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'pointer-events', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'requiredfeatures', 'restart', 'result', 'role', 'rotate', 'rx', 'ry', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'to', 'transform', 'transform-origin', 'type', 'u1', 'u2', 'unicode', 'values', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'viewbox', 'visibility', 'width', 'word-spacing', 'wrap', 'writing-mode', 'x', 'x1', 'x2', 'xchannelselector', 'xlink:href', 'xml:id', 'xml:space', 'xlink:title', 'xmlns', 'xmlns:xlink', 'y', 'y1', 'y2', 'z', 'zoomandpan', ]) // --- Allowed HTML attributes inside foreignObject --- const ALLOWED_HTML_ATTRS = new Set([ 'class', 'dir', 'href', // only on <a> 'id', 'lang', 'role', 'style', 'tabindex', 'title', ]) // Patterns for data-* and aria-* attributes const DATA_ATTR_PATTERN = /^data-/ const ARIA_ATTR_PATTERN = /^aria-/ // --- URI sanitization --- // Strip invisible whitespace that can bypass protocol checks // eslint-disable-next-line no-control-regex const INVISIBLE_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // Safe link protocols const SAFE_LINK_PROTOCOLS = /^(?:https?:|mailto:)/i // data: URI (for images, fonts) const DATA_URI = /^data:/i // data: URI for raster image formats (svg+xml handled separately below) const RASTER_DATA_URI = /^data:image\/(?:png|jpeg|jpg|gif|webp|avif|bmp|tiff|x-icon|vnd\.microsoft\.icon)[;,]/i // data: URI for SVG images — allowed on <image>/<feimage> only after recursive sanitization const SVG_DATA_URI = /^data:image\/svg\+xml[;,]/i // Fragment-only ref (#id) const FRAGMENT_REF = /^#/ // --- CSS sanitization --- function decodeCssEscapes(css: string): string { return css.replace(/\\([0-9a-fA-F]{1,6})\s?|\\([^\n])/g, (_, hex, literal) => { if (hex) { const codePoint = parseInt(hex, 16) if (codePoint > 0x10ffff || codePoint === 0) return '\uFFFD' return String.fromCodePoint(codePoint) } return literal }) } // Allowed data: MIME types in CSS url() const SAFE_CSS_DATA_MIME = /^data:(?:image\/(?:png|jpeg|jpg|gif|webp|avif)|font\/(?:woff2?|opentype|truetype|sfnt)|application\/(?:x-font-woff|font-woff2?|x-font-ttf|x-font-opentype|font-sfnt))[;,]/i function sanitizeCssValue(css: string): string { let decoded = decodeCssEscapes(css) // Strip @import — handle url() with semicolons inside quotes decoded = decoded.replace( /@import\s+(?:url\s*\([^)]*\)|"[^"]*"|'[^']*')[^;]*;?|@import\b[^;]*;?/gi, '' ) // Strip expression(), -moz-binding, behavior: decoded = decoded.replace(/expression\s*\([^)]*\)/gi, '') decoded = decoded.replace(/-moz-binding\s*:[^;]*/gi, '') decoded = decoded.replace(/behavior\s*:[^;]*/gi, '') // Sanitize url() — allow only data: with safe MIME types decoded = decoded.replace(/url\s*\(\s*(['"]?)(.*?)\1\s*\)/gis, (match, _quote, uri) => { const stripped = uri.replace(INVISIBLE_WHITESPACE, '') if (SAFE_CSS_DATA_MIME.test(stripped) || FRAGMENT_REF.test(stripped)) { return match } return '' }) return decoded } function sanitizeStyleElement(textContent: string): string { return sanitizeCssValue(textContent) } // --- Animation safety --- // Animation elements (<animate>, <set>) can overwrite attributes at runtime. // If attributeName targets a URI attr (href) or event handler (on*), the animation // can inject javascript: URIs or re-add stripped handlers, bypassing static sanitization. const ANIMATION_TAGS = new Set(['animate', 'set', 'animatecolor', 'animatetransform']) const DANGEROUS_ANIMATION_TARGETS = /^(?:href|xlink:href|on)/i function isAnimationDangerous(el: Element): boolean { const attrName = el.getAttribute('attributeName') if (!attrName) return false return DANGEROUS_ANIMATION_TARGETS.test(attrName.replace(INVISIBLE_WHITESPACE, '')) } // --- Event handler detection --- // Matches on* after normalizing invisible chars — catches all current and future event handlers const EVENT_HANDLER_PATTERN = /^on/i // --- SVG presentation attributes that accept url() references --- // These can exfiltrate data via external URL loads if not sanitized. const URL_BEARING_SVG_ATTRS = new Set([ 'clip-path', 'cursor', 'fill', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke', ]) // --- Node sanitization --- type SanitizeMode = 'svg' | 'html' // Guard against deep recursion: sanitizeSvgInner → ... → sanitizeUri → sanitizeEmbeddedSvgDataUri → sanitizeSvgInner const MAX_EMBED_DEPTH = 10 function decodeDataUri(value: string): string | null { const base64Idx = value.search(/;base64,/i) if (base64Idx >= 0) { const base64 = value.slice(base64Idx + 8) // 8 = ";base64,".length const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) return new TextDecoder().decode(bytes) } const commaIdx = value.indexOf(',') if (commaIdx < 0) return null return decodeURIComponent(value.slice(commaIdx + 1)) } function encodeAsSvgDataUri(svgText: string): string { const bytes = new TextEncoder().encode(svgText) const binaryStr = Array.from(bytes, (b) => String.fromCharCode(b)).join('') return 'data:image/svg+xml;base64,' + btoa(binaryStr) } function sanitizeEmbeddedSvgDataUri(value: string, depth: number): string | null { if (depth >= MAX_EMBED_DEPTH) { console.warn(`Embedded SVG data URI recursion depth limit (${MAX_EMBED_DEPTH}) reached`) return null } let svgText: string try { const decoded = decodeDataUri(value) if (decoded === null) return null svgText = decoded } catch { // Invalid base64 or malformed percent-encoding — treat as unsafe return null } const sanitized = sanitizeSvgInner(svgText, depth + 1) if (!sanitized) return null return encodeAsSvgDataUri(sanitized) } function sanitizeUri(el: Element, attrName: string, value: string, depth: number): string | null { const stripped = value.replace(INVISIBLE_WHITESPACE, '') const tagName = el.tagName.toLowerCase() // <image> and <feImage>: raster data: or recursively-sanitized svg+xml data: if (tagName === 'image' || tagName === 'feimage') { if (RASTER_DATA_URI.test(stripped)) return value if (SVG_DATA_URI.test(stripped)) return sanitizeEmbeddedSvgDataUri(stripped, depth) return null } // <use>: fragment-only (#id) if (tagName === 'use') { if (FRAGMENT_REF.test(stripped)) return value return null } // <a>: http, https, mailto only if (tagName === 'a') { if (SAFE_LINK_PROTOCOLS.test(stripped)) return value return null } // All other elements with href/xlink:href: data: or fragment if (DATA_URI.test(stripped) || FRAGMENT_REF.test(stripped)) return value return null } function sanitizeSvgAttributes(el: Element, depth: number): void { for (let i = el.attributes.length - 1; i >= 0; i--) { const attr = el.attributes[i] const name = attr.name.toLowerCase() // Strip ALL event handlers (on*) const normalized = name.replace(INVISIBLE_WHITESPACE, '') if (EVENT_HANDLER_PATTERN.test(normalized)) { el.removeAttribute(attr.name) continue } // Allow data-* and aria-* if (DATA_ATTR_PATTERN.test(name) || ARIA_ATTR_PATTERN.test(name)) { continue } if (!ALLOWED_SVG_ATTRS.has(name)) { el.removeAttribute(attr.name) continue } // URI attributes need context-dependent sanitization if (name === 'href' || name === 'xlink:href') { const sanitized = sanitizeUri(el, name, attr.value, depth) if (sanitized === null) { el.removeAttribute(attr.name) } else if (sanitized !== attr.value) { attr.value = sanitized } continue } // style attribute: sanitize CSS if (name === 'style') { attr.value = sanitizeCssValue(attr.value) continue } // Presentation attributes that accept url() references — sanitize to allow // only data: (safe MIME) and fragment (#id) refs, strip external URLs if (URL_BEARING_SVG_ATTRS.has(name) && /url\s*\(/i.test(attr.value)) { attr.value = sanitizeCssValue(attr.value) } } } function sanitizeHtmlAttributes(el: Element): void { const tagName = el.tagName.toLowerCase() for (let i = el.attributes.length - 1; i >= 0; i--) { const attr = el.attributes[i] const name = attr.name.toLowerCase() // Strip ALL event handlers const normalized = name.replace(INVISIBLE_WHITESPACE, '') if (EVENT_HANDLER_PATTERN.test(normalized)) { el.removeAttribute(attr.name) continue } // Allow data-* and aria-* if (DATA_ATTR_PATTERN.test(name) || ARIA_ATTR_PATTERN.test(name)) { continue } if (!ALLOWED_HTML_ATTRS.has(name)) { el.removeAttribute(attr.name) continue } // href only allowed on <a>, with safe protocols only if (name === 'href') { if (tagName !== 'a') { el.removeAttribute(attr.name) continue } const stripped = attr.value.replace(INVISIBLE_WHITESPACE, '') if (!SAFE_LINK_PROTOCOLS.test(stripped)) { el.removeAttribute(attr.name) } continue } // style attribute: sanitize CSS if (name === 'style') { attr.value = sanitizeCssValue(attr.value) } } } function sanitizeNode(node: Element, mode: SanitizeMode, depth: number): void { // Walk children in reverse so removals don't shift indices for (let i = node.children.length - 1; i >= 0; i--) { const child = node.children[i] const tag = child.tagName.toLowerCase() if (mode === 'svg') { if (tag === 'foreignobject') { // foreignObject: sanitize attrs as SVG, recurse children as HTML sanitizeSvgAttributes(child, depth) sanitizeNode(child, 'html', depth) } else if (tag === 'style') { // <style>: sanitize attrs, sanitize text content as CSS sanitizeSvgAttributes(child, depth) if (child.textContent) { child.textContent = sanitizeStyleElement(child.textContent) } } else if (ANIMATION_TAGS.has(tag) && isAnimationDangerous(child)) { // Animation targeting href/on* can inject javascript: URIs at runtime child.remove() } else if (ALLOWED_SVG_TAGS.has(tag)) { sanitizeSvgAttributes(child, depth) sanitizeNode(child, 'svg', depth) } else { child.remove() } } else { // HTML mode (inside foreignObject) if (BLOCKED_HTML_TAGS.has(tag)) { child.remove() } else if (ALLOWED_HTML_TAGS.has(tag)) { sanitizeHtmlAttributes(child) sanitizeNode(child, 'html', depth) } else { child.remove() } } } } function sanitizeSvgInner(svgText: string, depth: number): string { const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml') const parseError = doc.querySelector('parsererror') if (parseError) return '' const svg = doc.documentElement if (svg.tagName.toLowerCase() !== 'svg') return '' sanitizeSvgAttributes(svg, depth) sanitizeNode(svg, 'svg', depth) if (svg.children.length === 0) return '' return new XMLSerializer().serializeToString(svg) } /** * Sanitizes an SVG string by removing dangerous elements, attributes, and URIs * while preserving safe content including foreignObject (for text rendering), * style elements (for fonts with data: URLs), and animation elements. * Embedded SVG data URIs on `<image>`/`<feImage>` are recursively sanitized. * * Returns the sanitized SVG string, or an empty string if the input was * malformed (parse error) or contained no safe content after sanitization. * * @public */ export function sanitizeSvg(svgText: string): string { return sanitizeSvgInner(svgText, 0) }