Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/ui-svg-images/src/InlineSVG/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,38 @@ type: example
</InlineSVG>
</View>
```

### Using SVG sprite sheets with `<use>` elements

By default, `<use>` elements are stripped for security. When rendering SVG
sprite sheets that use `<use xlink:href="#id">` references to `<symbol>` or
`<path>` definitions, set `allowUseElement` to allow same-document fragment
references. External URLs are blocked for security.

```js
---
type: example
---
const spriteSvg = `<svg viewBox="0 0 120 40" xmlns="http://www.w3.org/2000/svg">
<defs>
<circle id="dot" cx="20" cy="20" r="18" />
</defs>
<use href="#dot" x="0" fill="#0374B5" />
<use href="#dot" x="40" fill="#03893D" />
<use href="#dot" x="80" fill="#E62429" />
</svg>`

render(
<div>
<p>
<code>allowUseElement</code> set: the three circles render:
</p>
<InlineSVG src={spriteSvg} width="9rem" height="3rem" allowUseElement />
<p>
Without <code>allowUseElement</code>: the <code>&lt;use&gt;</code>{' '}
elements are stripped, so nothing is drawn:
</p>
<InlineSVG src={spriteSvg} width="9rem" height="3rem" />
</div>
)
```
30 changes: 30 additions & 0 deletions packages/ui-svg-images/src/InlineSVG/__tests__/InlineSVG.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,5 +184,35 @@ describe('<InlineSVG />', () => {
expect(svg).toHaveAttribute('viewBox', '0 0 24 24')
expect(svg?.querySelector('path')).toHaveAttribute('d', 'M0 0L10 10')
})

describe('svg with <use> tags (allowUseElement)', () => {
const MJX_PLUS_ICON = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1.043ex" height="1.676ex" viewBox="0 -583 461 741">
<defs>
<path id="MJX-1-TEX-N-2B" stroke-width="1" d="M56 237T56 250T70 270H369V420L370 570Q380 583 397 583Q414 583 424 570V270H723Q740 270 740 250T723 230H424V-70Q424 -170 415 -180H385Q374 -170 366 -142V230H70Q56 237 56 250Z"></path>
</defs>
<g stroke="currentColor" fill="currentColor" stroke-width="0" transform="scale(1,-1)">
<use xlink:href="#MJX-1-TEX-N-2B" x="0" y="0"></use>
</g>
</svg>`

it('strips <use> and its glyph reference by default', () => {
const { container } = render(<InlineSVG src={MJX_PLUS_ICON} />)
const group = container.querySelector('g[role="presentation"]')

expect(group?.querySelector('use')).not.toBeInTheDocument()
})

it('renders the glyph via <use> when allowUseElement is set', () => {
const { container } = render(
<InlineSVG src={MJX_PLUS_ICON} allowUseElement />
)
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')
})
})
})
})
106 changes: 106 additions & 0 deletions packages/ui-svg-images/src/InlineSVG/__tests__/sanitizeSvg.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,5 +163,111 @@ describe('sanitizeSvg', () => {
expect(out).toContain('<p>')
expect(out).toContain('<h2>')
})

it('does not install its <use> fragment-only hook on the global singleton', () => {
// Our fragment-only <use> rule is a hook on our instance.
// The singleton must be unaffected, keeping non-fragment hrefs.
sanitizeSvg(`<svg><use href="#icon"/></svg>`, { allowUseElement: true })

const out = DOMPurifySingleton.sanitize(
`<svg><use href="https://example.com/icons.svg#icon"/></svg>`,
{
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 <use> elements by default', () => {
const out = sanitizeSvg(
`<svg><defs><path id="icon" d="M0 0L10 10"/></defs><use xlink:href="#icon"/></svg>`
)
expect(out).not.toMatch(/<use\b/i)
expect(out).toMatch(/<path\b/)
})

it('allows <use> with fragment reference when allowUseElement is true', () => {
const out = sanitizeSvg(
`<svg><defs><path id="icon" d="M0 0L10 10"/></defs><use xlink:href="#icon"/></svg>`,
{ allowUseElement: true }
)
expect(out).toMatch(/<use\b/)
expect(out).toMatch(/xlink:href="#icon"/)
expect(out).toMatch(/<path\b/)
})

it('allows href attribute when allowUseElement is true', () => {
const out = sanitizeSvg(
`<svg><defs><path id="icon" d="M0 0"/></defs><use href="#icon"/></svg>`,
{ allowUseElement: true }
)
expect(out).toMatch(/<use\b/)
expect(out).toMatch(/href="#icon"/)
})

it('strips external URLs from href even when allowUseElement is true', () => {
const out = sanitizeSvg(
`<svg><use href="https://example.com/icons.svg#icon"/></svg>`,
{ 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(
`<svg><use href="data:image/svg+xml,<svg>..."/></svg>`,
{ allowUseElement: true }
)
expect(out).not.toMatch(/href="data:/)
// <use> tag remains without href (safe but useless)
expect(out).toMatch(/<use\b/)
})

it('strips javascript: scheme from href when allowUseElement is true', () => {
const out = sanitizeSvg(`<svg><use href="javascript:alert(1)"/></svg>`, {
allowUseElement: true
})
expect(out).not.toMatch(/javascript:/)
// <use> tag remains without href (safe but useless)
expect(out).toMatch(/<use\b/)
})

it('preserves multiple <use> elements with fragment refs when allowUseElement is true', () => {
const out = sanitizeSvg(
`<svg><defs><symbol id="a"/><symbol id="b"/></defs><use xlink:href="#a"/><use xlink:href="#b"/></svg>`,
{ allowUseElement: true }
)
expect(out).toMatch(/xlink:href="#a"/)
expect(out).toMatch(/xlink:href="#b"/)
const useCount = (out.match(/<use\b/g) || []).length
expect(useCount).toBe(2)
})

it('preserves non-URI attributes everywhere when allowUseElement is true', () => {
const out = sanitizeSvg(
`<svg viewBox="0 0 120 40"><defs><circle id="dot" cx="20" cy="20" r="18"/></defs><use href="#dot" x="40" fill="red"/></svg>`,
{ allowUseElement: true }
)
expect(out).toMatch(/viewBox="0 0 120 40"/)
expect(out).toMatch(/<circle\b[^>]*cx="20"[^>]*cy="20"[^>]*r="18"/)
expect(out).toMatch(/<use\b[^>]*x="40"/)
expect(out).toMatch(/<use\b[^>]*fill="red"/)
})

it('preserves valid SVG attributes on <use> elements when allowUseElement is true', () => {
const out = sanitizeSvg(
`<svg><defs><path id="icon"/></defs><use xlink:href="#icon" class="my-icon"/></svg>`,
{ allowUseElement: true }
)
expect(out).toMatch(/xlink:href="#icon"/)
// <use> tag is rendered with the fragment reference
expect(out).toMatch(/<use\b/)
})
})
})
17 changes: 14 additions & 3 deletions packages/ui-svg-images/src/InlineSVG/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,20 @@ class InlineSVG extends Component<InlineSVGProps> {
}

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 =
Expand Down
9 changes: 8 additions & 1 deletion packages/ui-svg-images/src/InlineSVG/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ type InlineSVGOwnProps = {
* provides a reference to the underlying html root element
*/
elementRef?: (element: Element | null) => void
/**
* Allow `<use>` 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
Expand All @@ -85,7 +91,8 @@ const allowedProps: AllowedPropKeys = [
'height',
'inline',
'color',
'elementRef'
'elementRef',
'allowUseElement'
]

export type { InlineSVGProps, InlineSVGOwnProps, InlineSVGStyle }
Expand Down
40 changes: 34 additions & 6 deletions packages/ui-svg-images/src/InlineSVG/sanitizeSvg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,42 @@ const purify = createDOMPurify(
typeof window === 'undefined' ? undefined : window
)

// Sanitize a full `<svg>...</svg>` 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 <use> 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 `<svg>...</svg>` 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<string, unknown> = {
USE_PROFILES: { svg: true, svgFilters: true }
}

if (options?.allowUseElement) {
config.ADD_TAGS = ['use']
config.ADD_ATTR = ['href', 'xlink:href']
}
Comment on lines +66 to +69

return purify.sanitize(src, config as Parameters<typeof purify.sanitize>[1])
}

export default sanitizeSvg
Expand Down
Loading