diff --git a/packages/ui-svg-images/src/InlineSVG/README.md b/packages/ui-svg-images/src/InlineSVG/README.md index 6992239a21..9e17c6ea53 100644 --- a/packages/ui-svg-images/src/InlineSVG/README.md +++ b/packages/ui-svg-images/src/InlineSVG/README.md @@ -48,3 +48,38 @@ type: example ``` + +### Using SVG sprite sheets with `` elements + +By default, `` elements are stripped for security. When rendering SVG +sprite sheets that use `` references to `` or +`` definitions, set `allowUseElement` to allow same-document fragment +references. External URLs are blocked for security. + +```js +--- +type: example +--- +const spriteSvg = ` + + + + + + +` + +render( +
+

+ allowUseElement set: the three circles render: +

+ +

+ Without allowUseElement: the <use>{' '} + elements are stripped, so nothing is drawn: +

+ +
+) +``` diff --git a/packages/ui-svg-images/src/InlineSVG/__tests__/InlineSVG.test.tsx b/packages/ui-svg-images/src/InlineSVG/__tests__/InlineSVG.test.tsx index ae805c6009..c26d42b299 100644 --- a/packages/ui-svg-images/src/InlineSVG/__tests__/InlineSVG.test.tsx +++ b/packages/ui-svg-images/src/InlineSVG/__tests__/InlineSVG.test.tsx @@ -184,5 +184,35 @@ describe('', () => { expect(svg).toHaveAttribute('viewBox', '0 0 24 24') expect(svg?.querySelector('path')).toHaveAttribute('d', 'M0 0L10 10') }) + + describe('svg with tags (allowUseElement)', () => { + const MJX_PLUS_ICON = ` + + + + + + +` + + it('strips and its glyph reference by default', () => { + const { container } = render() + const group = container.querySelector('g[role="presentation"]') + + expect(group?.querySelector('use')).not.toBeInTheDocument() + }) + + it('renders the glyph via when allowUseElement is set', () => { + const { container } = render( + + ) + const group = container.querySelector('g[role="presentation"]') + + expect(group?.querySelector('path#MJX-1-TEX-N-2B')).toBeInTheDocument() + const use = group?.querySelector('use') + expect(use).toBeInTheDocument() + expect(use?.getAttribute('xlink:href')).toBe('#MJX-1-TEX-N-2B') + }) + }) }) }) diff --git a/packages/ui-svg-images/src/InlineSVG/__tests__/sanitizeSvg.test.tsx b/packages/ui-svg-images/src/InlineSVG/__tests__/sanitizeSvg.test.tsx index 9dd6b02493..88f9c3787e 100644 --- a/packages/ui-svg-images/src/InlineSVG/__tests__/sanitizeSvg.test.tsx +++ b/packages/ui-svg-images/src/InlineSVG/__tests__/sanitizeSvg.test.tsx @@ -163,5 +163,111 @@ describe('sanitizeSvg', () => { expect(out).toContain('

') expect(out).toContain('

') }) + + it('does not install its fragment-only hook on the global singleton', () => { + // Our fragment-only rule is a hook on our instance. + // The singleton must be unaffected, keeping non-fragment hrefs. + sanitizeSvg(``, { allowUseElement: true }) + + const out = DOMPurifySingleton.sanitize( + ``, + { + USE_PROFILES: { svg: true }, + ADD_TAGS: ['use'], + ADD_ATTR: ['href'] + } + ) + + expect(out).toMatch(/href="https:\/\/example\.com\/icons\.svg#icon"/) + }) + }) + + describe('allowUseElement option', () => { + it('strips elements by default', () => { + const out = sanitizeSvg( + `` + ) + expect(out).not.toMatch(/ with fragment reference when allowUseElement is true', () => { + const out = sanitizeSvg( + ``, + { allowUseElement: true } + ) + expect(out).toMatch(/ { + const out = sanitizeSvg( + ``, + { allowUseElement: true } + ) + expect(out).toMatch(/ { + const out = sanitizeSvg( + ``, + { allowUseElement: true } + ) + // Fragment reference with external URL should be stripped + expect(out).not.toMatch(/href="https:/) + }) + + it('strips data: URIs from href when allowUseElement is true', () => { + const out = sanitizeSvg( + ``, + { allowUseElement: true } + ) + expect(out).not.toMatch(/href="data:/) + // tag remains without href (safe but useless) + expect(out).toMatch(/ { + const out = sanitizeSvg(``, { + allowUseElement: true + }) + expect(out).not.toMatch(/javascript:/) + // tag remains without href (safe but useless) + expect(out).toMatch(/ elements with fragment refs when allowUseElement is true', () => { + const out = sanitizeSvg( + ``, + { allowUseElement: true } + ) + expect(out).toMatch(/xlink:href="#a"/) + expect(out).toMatch(/xlink:href="#b"/) + const useCount = (out.match(/ { + const out = sanitizeSvg( + ``, + { allowUseElement: true } + ) + expect(out).toMatch(/viewBox="0 0 120 40"/) + expect(out).toMatch(/]*cx="20"[^>]*cy="20"[^>]*r="18"/) + expect(out).toMatch(/]*x="40"/) + expect(out).toMatch(/]*fill="red"/) + }) + + it('preserves valid SVG attributes on elements when allowUseElement is true', () => { + const out = sanitizeSvg( + ``, + { allowUseElement: true } + ) + expect(out).toMatch(/xlink:href="#icon"/) + // tag is rendered with the fragment reference + expect(out).toMatch(/ { } render() { - const { style, title, description, focusable, src, styles, ...props } = - this.props - const safeSrc = typeof src === 'string' && src ? sanitizeSvg(src) : '' + const { + style, + title, + description, + focusable, + src, + styles, + allowUseElement, + ...props + } = this.props + const safeSrc = + typeof src === 'string' && src + ? sanitizeSvg(src, { allowUseElement }) + : '' // if width or height are 'auto', don't supply anything to the SVG const width = diff --git a/packages/ui-svg-images/src/InlineSVG/props.ts b/packages/ui-svg-images/src/InlineSVG/props.ts index 9eadfcbb06..41648f6a23 100644 --- a/packages/ui-svg-images/src/InlineSVG/props.ts +++ b/packages/ui-svg-images/src/InlineSVG/props.ts @@ -63,6 +63,12 @@ type InlineSVGOwnProps = { * provides a reference to the underlying html root element */ elementRef?: (element: Element | null) => void + /** + * Allow `` elements with `href`/`xlink:href` attributes. When enabled, + * only same-document fragment references (e.g. `#my-symbol`) are permitted; + * external URLs are blocked. + */ + allowUseElement?: boolean } type PropKeys = keyof InlineSVGOwnProps @@ -85,7 +91,8 @@ const allowedProps: AllowedPropKeys = [ 'height', 'inline', 'color', - 'elementRef' + 'elementRef', + 'allowUseElement' ] export type { InlineSVGProps, InlineSVGOwnProps, InlineSVGStyle } diff --git a/packages/ui-svg-images/src/InlineSVG/sanitizeSvg.ts b/packages/ui-svg-images/src/InlineSVG/sanitizeSvg.ts index d363961499..591bc91012 100644 --- a/packages/ui-svg-images/src/InlineSVG/sanitizeSvg.ts +++ b/packages/ui-svg-images/src/InlineSVG/sanitizeSvg.ts @@ -33,14 +33,42 @@ const purify = createDOMPurify( typeof window === 'undefined' ? undefined : window ) -// Sanitize a full `...` string. DOMPurify only operates when a DOM -// is available (i.e. in the browser); on the server it returns empty so unsafe -// content never lands in SSR output. Callers should pass the complete SVG — -// outer attributes are filtered in the same pass as the inner content. -function sanitizeSvg(src: string) { +// Restrict references to same-document fragments like #my-symbol, hooks +// are per-instance +if (purify.isSupported) { + purify.addHook('afterSanitizeAttributes', (node) => { + if (node.nodeName?.toLowerCase() !== 'use') return + + for (const attribute of ['href', 'xlink:href']) { + const value = node.getAttribute(attribute) + if (value !== null && !value.trim().startsWith('#')) { + node.removeAttribute(attribute) + } + } + }) +} + +type SanitizeSvgOptions = { + allowUseElement?: boolean +} + +// Sanitize a full `...` string; pass the complete SVG. +// Outer attributes are filtered alongside the inner content. +// DOMPurify needs a DOM, so SSR returns empty instead. +function sanitizeSvg(src: string, options?: SanitizeSvgOptions) { if (typeof src !== 'string' || src === '') return src if (!purify.isSupported) return '' - return purify.sanitize(src, { USE_PROFILES: { svg: true, svgFilters: true } }) + + const config: Record = { + USE_PROFILES: { svg: true, svgFilters: true } + } + + if (options?.allowUseElement) { + config.ADD_TAGS = ['use'] + config.ADD_ATTR = ['href', 'xlink:href'] + } + + return purify.sanitize(src, config as Parameters[1]) } export default sanitizeSvg