Skip to content

Commit

Permalink
feat: use DOMPurify with an allowlist based configuration to sanitize…
Browse files Browse the repository at this point in the history
… icons
  • Loading branch information
joshuaellis committed Dec 16, 2024
1 parent 661d644 commit c1a548a
Show file tree
Hide file tree
Showing 5 changed files with 478 additions and 18 deletions.
1 change: 1 addition & 0 deletions packages/sanity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@
"i18next": "^23.2.7",
"import-fresh": "^3.3.0",
"is-hotkey-esm": "^1.0.0",
"isomorphic-dompurify": "^2.19.0",
"jsdom": "^23.0.1",
"jsdom-global": "^3.0.2",
"json-lexer": "^1.2.0",
Expand Down
9 changes: 4 additions & 5 deletions packages/sanity/src/_internal/manifest/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,20 @@ import {buildTheme} from '@sanity/ui/theme'
import {type ComponentType, createElement, isValidElement, type ReactNode} from 'react'
import {isValidElementType} from 'react-is'
import {createDefaultIcon} from 'sanity'
import {ServerStyleSheet, StyleSheetManager} from 'styled-components'
import {type ServerStyleSheet, StyleSheetManager} from 'styled-components'

const theme = buildTheme()

interface SchemaIconProps {
icon?: ComponentType | ReactNode
title: string
subtitle?: string
sheet?: ServerStyleSheet
}

const SchemaIcon = ({icon, title, subtitle}: SchemaIconProps): JSX.Element => {
const sheet = new ServerStyleSheet()

const SchemaIcon = ({icon, title, subtitle, sheet}: SchemaIconProps): JSX.Element => {
return (
<StyleSheetManager sheet={sheet.instance}>
<StyleSheetManager sheet={sheet?.instance}>
<ThemeProvider theme={theme}>{normalizeIcon(icon, title, subtitle)}</ThemeProvider>
</StyleSheetManager>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import DOMPurify from 'isomorphic-dompurify'
import startCase from 'lodash/startCase'
import {createElement} from 'react'
import {renderToString} from 'react-dom/server'
Expand All @@ -23,6 +24,7 @@ import {
type StringSchemaType,
type Workspace,
} from 'sanity'
import {ServerStyleSheet} from 'styled-components'

import {SchemaIcon, type SchemaIconProps} from './Icon'
import {
Expand All @@ -47,6 +49,7 @@ import {
type ManifestValidationGroup,
type ManifestValidationRule,
} from './manifestTypes'
import {config} from './purifyConfig'

interface Context {
schema: Schema
Expand Down Expand Up @@ -533,9 +536,27 @@ const extractManifestTools = (tools: Workspace['tools']): ManifestTool[] =>
})

const resolveIcon = (props: SchemaIconProps): string | null => {
const sheet = new ServerStyleSheet()

try {
return renderToString(createElement(SchemaIcon, props))
/**
* You must render the element first so
* the style-sheet above can be populated
*/
const element = renderToString(createElement(SchemaIcon, {...props, sheet}))
const styleTags = sheet.getStyleTags()

/**
* We can then create a single string
* of HTML combining our styles and element
* before purifying below.
*/
const html = `${styleTags}${element}`.trim()

return DOMPurify.sanitize(html, config)
} catch (error) {
return null
} finally {
sheet.seal()
}
}
310 changes: 310 additions & 0 deletions packages/sanity/src/_internal/manifest/purifyConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
import {type Config} from 'isomorphic-dompurify'

/**
* This file maintains our sanitization configuration for DOMPurify.
* We use an allowlist for tags and attributes to ensure that only safe
* elements and attributes are allowed.
*
* This is easier to loosen as specs develop & use-cases are discovered.
*/

///////// Tags

const HTML_TAGS = ['img', 'style']

const SVG_TAGS = [
'svg',
'a',
'altglyph',
'altglyphdef',
'altglyphitem',
'animatecolor',
'animatemotion',
'animatetransform',
'circle',
'clippath',
'defs',
'desc',
'ellipse',
'filter',
'font',
'g',
'glyph',
'glyphref',
'hkern',
'image',
'line',
'lineargradient',
'marker',
'mask',
'metadata',
'mpath',
'path',
'pattern',
'polygon',
'polyline',
'radialgradient',
'rect',
'stop',
'style',
'switch',
'symbol',
'text',
'textpath',
'title',
'tref',
'tspan',
'view',
'vkern',
] as const

const SVG_FILTER_TAGS = [
'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',
] as const

const ALLOWED_TAGS: Config['ALLOWED_TAGS'] = [...SVG_TAGS, ...HTML_TAGS, ...SVG_FILTER_TAGS]

///////// Attributes

const HTML_ATTRIBUTES = [
'alt',
'class',
'crossorigin',
'decoding',
'elementtiming',
'fetchpriority',
'height',
'loading',
'src',
'srcset',
'style',
'width',
]

const SVG_ATTRIBUTES = [
'accent-height',
'accumulate',
'additive',
'alignment-baseline',
'amplitude',
'ascent',
'attributename',
'attributetype',
'azimuth',
'basefrequency',
'baseline-shift',
'begin',
'bias',
'by',
'class',
'clip',
'clippathunits',
'clip-path',
'clip-rule',
'color',
'color-interpolation',
'color-interpolation-filters',
'color-profile',
'color-rendering',
'cx',
'cy',
'd',
'dx',
'dy',
'diffuseconstant',
'direction',
'display',
'divisor',
'dur',
'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',
'fx',
'fy',
'g1',
'g2',
'glyph-name',
'glyphref',
'gradientunits',
'gradienttransform',
'height',
'href',
'id',
'image-rendering',
'in',
'in2',
'intercept',
'k',
'k1',
'k2',
'k3',
'k4',
'kerning',
'keypoints',
'keysplines',
'keytimes',
'lang',
'lengthadjust',
'letter-spacing',
'kernelmatrix',
'kernelunitlength',
'lighting-color',
'local',
'marker-end',
'marker-mid',
'marker-start',
'markerheight',
'markerunits',
'markerwidth',
'maskcontentunits',
'maskunits',
'max',
'mask',
'media',
'method',
'mode',
'min',
'name',
'numoctaves',
'offset',
'operator',
'opacity',
'order',
'orient',
'orientation',
'origin',
'overflow',
'paint-order',
'path',
'pathlength',
'patterncontentunits',
'patterntransform',
'patternunits',
'points',
'preservealpha',
'preserveaspectratio',
'primitiveunits',
'r',
'rx',
'ry',
'radius',
'refx',
'refy',
'repeatcount',
'repeatdur',
'restart',
'result',
'rotate',
'scale',
'seed',
'shape-rendering',
'slope',
'specularconstant',
'specularexponent',
'spreadmethod',
'startoffset',
'stddeviation',
'stitchtiles',
'stop-color',
'stop-opacity',
'stroke-dasharray',
'stroke-dashoffset',
'stroke-linecap',
'stroke-linejoin',
'stroke-miterlimit',
'stroke-opacity',
'stroke',
'stroke-width',
'style',
'surfacescale',
'systemlanguage',
'tabindex',
'tablevalues',
'targetx',
'targety',
'transform',
'transform-origin',
'text-anchor',
'text-decoration',
'text-rendering',
'textlength',
'type',
'u1',
'u2',
'unicode',
'values',
'viewbox',
'visibility',
'version',
'vert-adv-y',
'vert-origin-x',
'vert-origin-y',
'width',
'word-spacing',
'wrap',
'writing-mode',
'xchannelselector',
'ychannelselector',
'x',
'x1',
'x2',
'xmlns',
'y',
'y1',
'y2',
'z',
'zoomandpan',
] as const

const ALLOWED_ATTR: Config['ALLOWED_ATTR'] = [...SVG_ATTRIBUTES, ...HTML_ATTRIBUTES]

const config = {
ALLOWED_ATTR,
ALLOWED_TAGS,
/**
* Required to allow for the use of `style` tags,
* namely rendering the style tags from `styled-components`
*/
FORCE_BODY: true,
} satisfies Config

export {config}
Loading

0 comments on commit c1a548a

Please sign in to comment.