import { createShapeId, toRichText } from '@tldraw/editor' import { describe, expect, it, vi } from 'vitest' import { TestEditor } from '../../../test/TestEditor' import { sanitizeSvg } from './sanitizeSvg' function wrap(inner: string, attrs = 'xmlns="http://www.w3.org/2000/svg"'): string { return `${inner}` } describe('sanitizeSvg', () => { describe('attack vectors — must strip', () => { it('removes ')) expect(result).not.toContain('', () => { const result = sanitizeSvg(wrap('')) expect(result).not.toContain('onerror') expect(result).toContain(' inside ', () => { const result = sanitizeSvg( wrap( '' ) ) expect(result).not.toContain('', () => { const result = sanitizeSvg( '' ) expect(result).not.toContain('onload') expect(result).toContain('', () => { const result = sanitizeSvg(wrap('')) expect(result).not.toContain('onclick') }) it('strips onbegin from ', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('onbegin') expect(result).toContain('', () => { const result = sanitizeSvg(wrap('click')) expect(result).not.toContain('javascript') }) it('strips https: href from ', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('evil.com') }) it('strips https: xlink:href from ', () => { const result = sanitizeSvg( wrap( '', 'xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"' ) ) expect(result).not.toContain('evil.com') }) it('strips external href from ', () => { const result = sanitizeSvg(wrap('')) expect(result).not.toContain('evil.com') }) it('strips data: href from ', () => { const result = sanitizeSvg(wrap('')) expect(result).not.toContain('data:') }) it('strips external href from ', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('evil.com') }) it('strips @import in ') ) expect(result).not.toContain('@import') expect(result).not.toContain('evil.com') }) it('strips external url() in ' ) ) expect(result).not.toContain('evil.com') }) it('strips external url() in @font-face in ' ) ) expect(result).not.toContain('evil.com') }) it('strips external url() in style attribute', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('evil.com') }) it('strips cursor url() in style attribute', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('evil.com') }) it('blocks CSS escape bypass in href', () => { // \6A decodes to 'j', making "javascript:" const result = sanitizeSvg(wrap('x')) // The href might still be there but it shouldn't contain javascript protocol // Since this is an SVG a element, it uses URI sanitization which strips invisible whitespace // and checks protocol expect(result).not.toContain('alert') }) it('blocks null byte bypass in href', () => { const result = sanitizeSvg(wrap('x')) expect(result).not.toContain('alert') }) it('strips mixed-case event handlers', () => { const r1 = sanitizeSvg(wrap('')) expect(r1).not.toContain('OnError') const r2 = sanitizeSvg(wrap('')) expect(r2).not.toContain('ONCLICK') }) it('removes ' ) ) expect(result).not.toContain(' inside ', () => { const result = sanitizeSvg( wrap( '' ) ) expect(result).not.toContain(' inside ', () => { const result = sanitizeSvg( wrap( '' ) ) expect(result).not.toContain(' inside ', () => { const result = sanitizeSvg( wrap( '
' ) ) expect(result).not.toContain(' inside ', () => { const result = sanitizeSvg( wrap( '' ) ) // The nested svg should be removed; the foreignObject is preserved expect(result).toContain('foreignObject') expect(result).not.toContain(' inside ', () => { const result = sanitizeSvg( wrap( '' ) ) expect(result).not.toContain(' { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('expression') expect(result).not.toContain('alert') }) it('strips -moz-binding in CSS', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('-moz-binding') }) it('strips behavior: in CSS', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('behavior') }) it('returns empty string for fully malicious SVG', () => { const result = sanitizeSvg(wrap('')) expect(result).toBe('') }) it('returns empty string for invalid SVG', () => { const result = sanitizeSvg('this is not svg') expect(result).toBe('') }) it('rejects non-svg root element', () => { const result = sanitizeSvg( '' ) expect(result).toBe('') }) it('removes targeting href (XSS via animation)', () => { const result = sanitizeSvg( wrap( 'x' ) ) expect(result).not.toContain('javascript') expect(result).not.toContain('attributeName="href"') expect(result).toContain(' targeting href', () => { const result = sanitizeSvg( wrap( 'x' ) ) expect(result).not.toContain('javascript') expect(result).not.toContain('attributeName="href"') }) it('removes targeting href', () => { const result = sanitizeSvg( wrap( 'x' ) ) expect(result).not.toContain('attributeName="href"') }) it('removes targeting xlink:href', () => { const result = sanitizeSvg( wrap( 'x', 'xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"' ) ) expect(result).not.toContain('attributeName="xlink:href"') }) it('removes targeting on* attributes', () => { const result = sanitizeSvg( wrap( '' ) ) expect(result).not.toContain('attributeName="onclick"') }) it('strips multiline CSS url() values', () => { const result = sanitizeSvg( wrap( '' ) ) expect(result).not.toContain('evil.com') }) it('blocks fully-malicious data:image/svg+xml on ', () => { // inner SVG has no safe content after sanitization, so href is removed const result = sanitizeSvg( wrap( '' ) ) expect(result).not.toContain('data:image/svg+xml') }) it('blocks data:image/svg+xml in CSS url()', () => { const result = sanitizeSvg( wrap( '' ) ) expect(result).not.toContain('data:image/svg+xml') }) it('strips @import with semicolons inside quoted URL', () => { const result = sanitizeSvg( wrap( '' ) ) expect(result).not.toContain('evil.com') expect(result).not.toContain('@import') }) it('strips external url() in fill attribute', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('evil.com') }) it('strips external url() in filter attribute', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('evil.com') }) it('strips external url() in clip-path attribute', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('evil.com') }) it('strips external url() in mask attribute', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('evil.com') }) it('strips external url() in marker-end attribute', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('evil.com') }) it('strips external url() in stroke attribute', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('evil.com') }) it('strips uppercase URL() in presentation attributes', () => { const result = sanitizeSvg( wrap('') ) expect(result).not.toContain('evil.com') }) it('does not throw on CSS escapes above Unicode max', () => { const result = sanitizeSvg(wrap('')) expect(result).toContain(' { it('preserves basic SVG shapes', () => { const svg = wrap( '' + '' + '' ) const result = sanitizeSvg(svg) expect(result).toContain(' { const svg = wrap( '' + '' + '' ) const result = sanitizeSvg(svg) expect(result).toContain('linearGradient') expect(result).toContain('radialGradient') expect(result).toContain(' { const svg = wrap( '' + '' + '' ) const result = sanitizeSvg(svg) expect(result).toContain('feGaussianBlur') expect(result).toContain('feDropShadow') expect(result).toContain('feBlend') // filter="url(#f)" must survive url-bearing attr sanitization expect(result).toContain('url(#f)') }) it('preserves clipPath, mask, pattern, marker', () => { const svg = wrap( '' + '' + '' + '' + '' ) const result = sanitizeSvg(svg) expect(result).toContain('clipPath') expect(result).toContain('mask') expect(result).toContain('pattern') expect(result).toContain('marker') }) it('preserves data: href on ', () => { const svg = wrap('') const result = sanitizeSvg(svg) expect(result).toContain('data:image/png;base64,iVBOR') }) it('preserves safe data:image/svg+xml href on ', () => { // base64 of const innerSvg = '' const b64 = btoa(innerSvg) const svg = wrap(``) const result = sanitizeSvg(svg) expect(result).toContain('data:image/svg+xml;base64,') expect(result).toContain('', () => { // SVG with both safe content and a script tag const innerSvg = '' const b64 = btoa(innerSvg) const svg = wrap(``) const result = sanitizeSvg(svg) // Should keep the image with sanitized SVG data URI expect(result).toContain('data:image/svg+xml;base64,') // Decode the embedded SVG to verify script was stripped const match = result.match(/data:image\/svg\+xml;base64,([A-Za-z0-9+/=]+)/) expect(match).toBeTruthy() const decoded = atob(match![1]) expect(decoded).toContain('', () => { const svg = wrap('') const result = sanitizeSvg(svg) expect(result).toContain('href="#r"') }) it('preserves data: font URL in ' + '' ) const result = sanitizeSvg(svg) expect(result).toContain('data:font/woff2;base64,d09GMg') }) it('preserves foreignObject with safe HTML content', () => { const svg = wrap( '' + '

Hello world

' + 'text' + '
  • item
' + 'codeitalicbold' + '
' ) const result = sanitizeSvg(svg) expect(result).toContain('foreignObject') expect(result).toContain('', () => { const svg = wrap('link') const result = sanitizeSvg(svg) expect(result).toContain('https://example.com') }) it('preserves https link in inside foreignObject', () => { const svg = wrap( '' + '' + '
' ) const result = sanitizeSvg(svg) expect(result).toContain('https://example.com') }) it('preserves safe inline styles', () => { const svg = wrap('') const result = sanitizeSvg(svg) expect(result).toContain('fill: red') expect(result).toContain('stroke: blue') }) it('preserves transform, viewBox, preserveAspectRatio', () => { const svg = '' const result = sanitizeSvg(svg) expect(result).toContain('viewBox') expect(result).toContain('transform') }) it('preserves data-* and aria-* attributes', () => { const svg = wrap('') const result = sanitizeSvg(svg) expect(result).toContain('data-testid') expect(result).toContain('aria-label') }) it('preserves width/height on svg element', () => { const svg = '' const result = sanitizeSvg(svg) expect(result).toContain('width="200"') expect(result).toContain('height="100"') }) it('preserves animation elements without event handlers', () => { const svg = wrap( '' + '' + '' ) const result = sanitizeSvg(svg) expect(result).toContain('', () => { const svg = wrap( '' + '' + '' ) const result = sanitizeSvg(svg) expect(result).toContain('url(#grad)') }) it('preserves safe targeting non-URI attributes', () => { const svg = wrap( '' + '' + '' + '' ) const result = sanitizeSvg(svg) expect(result).toContain('attributeName="opacity"') expect(result).toContain('attributeName="fill"') }) it('preserves data: image in CSS url()', () => { const svg = wrap( '' + '' ) const result = sanitizeSvg(svg) expect(result).toContain('data:image/png;base64,iVBOR') }) }) describe('round-trip — tldraw SVG export survives sanitization', () => { vi.useRealTimers() it('preserves tldraw-exported SVG with text shapes', async () => { const editor = new TestEditor() const geoId = createShapeId('geo') editor.createShapes([ { id: geoId, type: 'geo', x: 0, y: 0, props: { w: 200, h: 100, richText: toRichText('Hello world'), }, }, ]) editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const original = exported!.svg const sanitized = sanitizeSvg(original) // Must not be empty expect(sanitized).not.toBe('') // Must still contain the SVG root expect(sanitized).toContain(' { const editor = new TestEditor() editor.createShapes([ { id: createShapeId('rect'), type: 'geo', x: 0, y: 0, props: { w: 100, h: 100, geo: 'rectangle' }, }, { id: createShapeId('ellipse'), type: 'geo', x: 150, y: 0, props: { w: 100, h: 100, geo: 'ellipse' }, }, ]) editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain(' { const editor = new TestEditor() editor.createShapes([ { id: createShapeId('patternRect'), type: 'geo', x: 0, y: 0, props: { w: 100, h: 100, fill: 'pattern' }, }, ]) editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const original = exported!.svg const sanitized = sanitizeSvg(original) expect(sanitized).not.toBe('') // Pattern fill uses , , in defs if (original.includes(' { const editor = new TestEditor() const startId = createShapeId('start') const endId = createShapeId('end') editor.createShapes([ { id: startId, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 }, }, { id: endId, type: 'geo', x: 300, y: 0, props: { w: 100, h: 100 }, }, ]) editor.setCurrentTool('arrow') editor.pointerDown(50, 50) editor.pointerMove(350, 50) editor.pointerUp() editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const original = exported!.svg const sanitized = sanitizeSvg(original) expect(sanitized).not.toBe('') expect(sanitized).toContain(' { const editor = new TestEditor() editor.setCurrentTool('draw') editor.pointerDown(0, 0) editor.pointerMove(50, 50) editor.pointerMove(100, 0) editor.pointerUp() editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain(' { const editor = new TestEditor() editor.createShapes([ { id: createShapeId('note'), type: 'note', x: 0, y: 0, props: { richText: toRichText('Note text'), }, }, ]) editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain('Note text') }) it('preserves text shape', async () => { const editor = new TestEditor() editor.createShapes([ { id: createShapeId('text'), type: 'text', x: 0, y: 0, props: { richText: toRichText('Plain text shape'), autoSize: true, }, }, ]) editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain('Plain text shape') expect(sanitized).toContain('foreignObject') }) it('preserves highlight shape', async () => { const editor = new TestEditor() editor.setCurrentTool('highlight') editor.pointerDown(0, 0) editor.pointerMove(50, 50) editor.pointerMove(100, 0) editor.pointerUp() editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain(' { const editor = new TestEditor() editor.setCurrentTool('line') editor.pointerDown(0, 0) editor.pointerMove(100, 100) editor.pointerUp() editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain('