diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 1975d350e1..c42a3657cf 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -28,6 +28,7 @@ import transformer, { removeMkdocsHeader, modifyCss, onCssReady, + sanitizeDOM, } from '../transformers'; import { docStorageURL } from '../../config'; import URLFormatter from '../urlFormatter'; @@ -88,6 +89,7 @@ export const Reader = () => { // Pre-render const transformedElement = transformer(state.value as string, [ + sanitizeDOM(), addBaseUrl({ docStorageURL, componentId, diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index 4e12dcae48..c8568282cf 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -20,6 +20,7 @@ export * from './addLinkClickListener'; export * from './removeMkdocsHeader'; export * from './modifyCss'; export * from './onCssReady'; +export * from './sanitizeDOM'; export type Transformer = (dom: Element) => Element; @@ -37,7 +38,9 @@ function transform( throw new Error('dom is not a recognized type'); } - transformers.forEach(transformer => transformer(dom)); + transformers.forEach(transformer => { + dom = transformer(dom); + }); return dom; } diff --git a/plugins/techdocs/src/reader/transformers/sanitizeDOM/attributes.ts b/plugins/techdocs/src/reader/transformers/sanitizeDOM/attributes.ts new file mode 100644 index 0000000000..e82e6929c3 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/sanitizeDOM/attributes.ts @@ -0,0 +1,386 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This is the source of truth of what attributes we support in TechDocs. + */ + +export const html = [ + 'accept', + 'action', + 'align', + 'alt', + 'autocapitalize', + 'autocomplete', + 'autopictureinpicture', + 'autoplay', + 'background', + 'bgcolor', + 'border', + 'capture', + 'cellpadding', + 'cellspacing', + 'checked', + 'cite', + 'class', + 'clear', + 'color', + 'cols', + 'colspan', + 'controls', + 'controlslist', + 'coords', + 'crossorigin', + 'datetime', + 'decoding', + 'default', + 'dir', + 'disabled', + 'disablepictureinpicture', + 'disableremoteplayback', + 'download', + 'draggable', + 'enctype', + 'enterkeyhint', + 'face', + 'for', + 'headers', + 'height', + 'hidden', + 'high', + 'href', + 'hreflang', + 'id', + 'inputmode', + 'integrity', + 'ismap', + 'kind', + 'label', + 'lang', + 'list', + 'loading', + 'loop', + 'low', + 'max', + 'maxlength', + 'media', + 'method', + 'min', + 'minlength', + 'multiple', + 'muted', + 'name', + 'noshade', + 'novalidate', + 'nowrap', + 'open', + 'optimum', + 'pattern', + 'placeholder', + 'playsinline', + 'poster', + 'preload', + 'pubdate', + 'radiogroup', + 'readonly', + 'rel', + 'required', + 'rev', + 'reversed', + 'role', + 'rows', + 'rowspan', + 'spellcheck', + 'scope', + 'selected', + 'shape', + 'size', + 'sizes', + 'span', + 'srclang', + 'start', + 'src', + 'srcset', + 'step', + 'style', + 'summary', + 'tabindex', + 'title', + 'translate', + 'type', + 'usemap', + 'valign', + 'value', + 'width', + 'xmlns', +]; + +export const svg = [ + 'accent-height', + 'accumulate', + 'additive', + 'alignment-baseline', + 'ascent', + 'attributename', + 'attributetype', + 'azimuth', + 'basefrequency', + 'baseline-shift', + 'begin', + 'bias', + 'by', + 'class', + 'clip', + 'clip-path', + 'clip-rule', + 'color', + 'color-interpolation', + 'color-interpolation-filters', + 'color-profile', + 'color-rendering', + 'cx', + 'cy', + 'd', + 'dx', + 'dy', + 'data', + 'diffuseconstant', + 'direction', + 'display', + 'divisor', + 'dur', + 'edgemode', + 'elevation', + 'end', + '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', + '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', + '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', + 'tabindex', + 'targetx', + 'targety', + 'transform', + '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', +]; + +export const mathMl = [ + 'accent', + 'accentunder', + 'align', + 'bevelled', + 'close', + 'columnsalign', + 'columnlines', + 'columnspan', + 'denomalign', + 'depth', + 'dir', + 'display', + 'displaystyle', + 'encoding', + 'fence', + 'frame', + 'height', + 'href', + 'id', + 'largeop', + 'length', + 'linethickness', + 'lspace', + 'lquote', + 'mathbackground', + 'mathcolor', + 'mathsize', + 'mathvariant', + 'maxsize', + 'minsize', + 'movablelimits', + 'notation', + 'numalign', + 'open', + 'rowalign', + 'rowlines', + 'rowspacing', + 'rowspan', + 'rspace', + 'rquote', + 'scriptlevel', + 'scriptminsize', + 'scriptsizemultiplier', + 'selection', + 'separator', + 'separators', + 'stretchy', + 'subscriptshift', + 'supscriptshift', + 'symmetric', + 'voffset', + 'width', + 'xmlns', +]; + +export const xml = [ + 'xlink:href', + 'xml:id', + 'xlink:title', + 'xml:space', + 'xmlns:xlink', +]; + +/** + * Anything in here will be supported as HTML attributes from TechDocs. + * + * @note Be conscious about what you add here. It can affect the TechDocs experience. For + * some review before making a PR, reach out in the #docs-like-code channel in Discord first. + */ +export const TECHDOCS_ALLOWED_ATTRIBUTES = { + '*': [...html, ...svg, ...mathMl, ...xml, 'data-*'], +}; diff --git a/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.test.ts b/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.test.ts new file mode 100644 index 0000000000..9754437c1a --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createTestShadowDom, FIXTURES } from '../../../test-utils'; +import { Transformer, sanitizeDOM } from '..'; + +const injectMaliciousLink = (): Transformer => dom => { + const link = document.createElement('a'); + link.setAttribute('id', 'test-malicious-link'); + link.setAttribute('onclick', 'alert("Hello world");'); + dom.querySelector('body')?.appendChild(link); + return dom; +}; + +describe('sanitizeDOM', () => { + it('contains a script tag', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE); + + expect(shadowDom.querySelectorAll('script').length).toBeGreaterThan(0); + }); + + it('does not contain a script tag', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + preTransformers: [sanitizeDOM()], + postTransformers: [], + }); + + expect(shadowDom.querySelectorAll('script').length).toBe(0); + }); + + it('contains link with a onClick attribute', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + preTransformers: [injectMaliciousLink()], + postTransformers: [], + }); + + expect( + shadowDom.querySelector('#test-malicious-link')?.hasAttribute('onclick'), + ).toBeTruthy(); + }); + + it('does not contain link with a onClick attribute', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + preTransformers: [sanitizeDOM()], + postTransformers: [], + }); + + expect( + shadowDom.querySelector('#test-malicious-link')?.hasAttribute('onclick'), + ).toBeFalsy(); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.ts b/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.ts new file mode 100644 index 0000000000..863698e16d --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import sanitizeHtml from 'sanitize-html'; +import type { Transformer } from '..'; +import { TECHDOCS_ALLOWED_TAGS } from './tags'; +import { TECHDOCS_ALLOWED_ATTRIBUTES } from './attributes'; + +export const sanitizeDOM = (): Transformer => { + return dom => { + const sanitizedHtml = sanitizeHtml(dom.innerHTML, { + allowedTags: TECHDOCS_ALLOWED_TAGS, + allowedAttributes: TECHDOCS_ALLOWED_ATTRIBUTES, + allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'data', 'blob'], + }); + + return new DOMParser().parseFromString(sanitizedHtml, 'text/html') + .documentElement; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/sanitizeDOM/tags.ts b/plugins/techdocs/src/reader/transformers/sanitizeDOM/tags.ts new file mode 100644 index 0000000000..85823cbe8a --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/sanitizeDOM/tags.ts @@ -0,0 +1,251 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This is the source of truth for what HTML tags we support in TechDocs. + */ + +// prettier-ignore +export const html = [ + 'a', 'abbr', 'acronym', + 'address', 'area', 'article', + 'aside', 'audio', 'b', 'bdi', + 'bdo', 'big', + 'blink', 'blockquote', + 'body', + 'br', 'button', + 'canvas', 'caption', 'center', 'cite', 'code', + 'col', 'colgroup', + 'content', + 'data', + 'datalist', + 'dd', + 'decorator', + 'del', + 'details', + 'dfn', + 'dir', + 'div', + 'dl', + 'dt', + 'element', + 'em', + 'fieldset', + 'figcaption', + 'figure', + 'font', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hgroup', + 'hr', + 'html', + 'i', + 'img', + 'input', + 'ins', + 'kbd', + 'label', + 'legend', + 'li', + 'link', + 'main', + 'map', + 'mark', + 'marquee', + 'menu', + 'menuitem', + 'meter', + 'nav', + 'nobr', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'picture', + 'pre', + 'progress', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'section', + 'select', + 'shadow', + 'small', + 'source', + 'spacer', + 'span', + 'strike', + 'strong', + 'style', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'template', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'tr', + 'track', + 'tt', + 'u', + 'ul', + 'var', + 'video', + 'wbr', +]; + +// SVG +export const svg = [ + 'svg', + 'a', + 'altglyph', + 'altglyphdef', + 'altglyphitem', + 'animatecolor', + 'animatemotion', + 'animatetransform', + 'audio', + 'canvas', + 'circle', + 'clippath', + 'defs', + 'desc', + 'ellipse', + 'filter', + 'font', + 'g', + 'glyph', + 'glyphref', + 'hkern', + 'image', + 'line', + 'lineargradient', + 'marker', + 'mask', + 'metadata', + 'mpath', + 'object', + 'path', + 'pattern', + 'polygon', + 'polyline', + 'radialgradient', + 'rect', + 'stop', + 'style', + 'switch', + 'symbol', + 'text', + 'textpath', + 'title', + 'tref', + 'tspan', + 'video', + 'view', + 'vkern', +]; + +export const svgFilters = [ + 'feBlend', + 'feColorMatrix', + 'feComponentTransfer', + 'feComposite', + 'feConvolveMatrix', + 'feDiffuseLighting', + 'feDisplacementMap', + 'feDistantLight', + 'feFlood', + 'feFuncA', + 'feFuncB', + 'feFuncG', + 'feFuncR', + 'feGaussianBlur', + 'feMerge', + 'feMergeNode', + 'feMorphology', + 'feOffset', + 'fePointLight', + 'feSpecularLighting', + 'feSpotLight', + 'feTile', + 'feTurbulence', +]; + +export const mathMl = [ + 'math', + 'menclose', + 'merror', + 'mfenced', + 'mfrac', + 'mglyph', + 'mi', + 'mlabeledtr', + 'mmultiscripts', + 'mn', + 'mo', + 'mover', + 'mpadded', + 'mphantom', + 'mroot', + 'mrow', + 'ms', + 'mspace', + 'msqrt', + 'mstyle', + 'msub', + 'msup', + 'msubsup', + 'mtable', + 'mtd', + 'mtext', + 'mtr', + 'munder', + 'munderover', +]; + +export const text = ['#text']; + +/** + * Anything in here will be executed as HTML tags from TechDocs. + * + * @note Be conscious about what you add here. It can affect the TechDocs experience. For + * some review before making a PR, reach out in the #docs-like-code channel in Discord first. + */ +export const TECHDOCS_ALLOWED_TAGS = [ + ...html, + ...svg, + ...svgFilters, + ...mathMl, + ...text, + 'iframe', +];