Merge pull request #1540 from spotify/mob/techdocs-sanitize-html

techdocs: sanitize html
This commit is contained in:
Emma Indal
2020-07-07 11:31:38 +02:00
committed by GitHub
15 changed files with 811 additions and 20 deletions
+2 -1
View File
@@ -31,7 +31,8 @@
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
"react-use": "^14.2.0",
"sanitize-html": "^1.27.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.12",
@@ -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,
@@ -39,13 +39,14 @@ describe('addBaseUrl', () => {
it('contains transformed absolute paths', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [
preTransformers: [
addBaseUrl({
docStorageURL: DOC_STORAGE_URL,
componentId: 'example-docs',
path: '',
}),
],
postTransformers: [],
});
expect(getSample(shadowDom, 'img', 'src')).toEqual([
@@ -73,13 +74,14 @@ describe('addBaseUrl', () => {
<script src="../assets/javascripts/vendor.d710d30a.min.js"></script>
`,
{
transformers: [
preTransformers: [
addBaseUrl({
docStorageURL: DOC_STORAGE_URL,
componentId: 'example-docs',
path: 'examplepath',
}),
],
postTransformers: [],
},
);
@@ -108,13 +110,14 @@ describe('addBaseUrl', () => {
<script src="../assets/javascripts/vendor.d710d30a.min.js"></script>
`,
{
transformers: [
preTransformers: [
addBaseUrl({
docStorageURL: DOC_STORAGE_URL,
componentId: 'example-docs',
path: 'examplepath/',
}),
],
postTransformers: [],
},
);
@@ -21,7 +21,8 @@ describe('addLinkClickListener', () => {
it('calls onClick when a link has been clicked', () => {
const fn = jest.fn();
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [
preTransformers: [],
postTransformers: [
addLinkClickListener({
onClick: fn,
}),
@@ -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;
}
@@ -22,7 +22,8 @@ describe('modifyCss', () => {
const shadowDom = createTestShadowDom(
`<div class="md-typeset" style="font-size: 0.8em"></div>`,
{
transformers: [],
preTransformers: [],
postTransformers: [],
},
);
@@ -37,13 +38,14 @@ describe('modifyCss', () => {
const shadowDom = createTestShadowDom(
`<div class="md-typeset" style="font-size: 1px"></div>`,
{
transformers: [
preTransformers: [
modifyCss({
cssTransforms: {
'.md-typeset': [{ 'font-size': '1em' }],
},
}),
],
postTransformers: [],
},
);
@@ -42,7 +42,8 @@ describe('onCssReady', () => {
const onLoaded = jest.fn();
createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [
preTransformers: [],
postTransformers: [
onCssReady({
docStorageURL,
onLoading,
@@ -61,7 +62,8 @@ describe('onCssReady', () => {
const onLoaded = jest.fn();
createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [
preTransformers: [],
postTransformers: [
addBaseUrl({
docStorageURL,
componentId: 'mkdocs',
@@ -20,7 +20,8 @@ import { removeMkdocsHeader } from '../transformers';
describe('removeMkdocsHeader', () => {
it('does not remove mkdocs header', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [],
preTransformers: [],
postTransformers: [],
});
expect(shadowDom.querySelector('.md-header')).toBeTruthy();
@@ -28,7 +29,8 @@ describe('removeMkdocsHeader', () => {
it('does remove mkdocs header', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [removeMkdocsHeader()],
preTransformers: [removeMkdocsHeader()],
postTransformers: [],
});
expect(shadowDom.querySelector('.md-header')).toBeFalsy();
@@ -43,7 +43,8 @@ describe('rewriteDocLinks', () => {
<a href="example-docs/example-page">Test Sub Page</a>
`,
{
transformers: [rewriteDocLinks()],
preTransformers: [rewriteDocLinks()],
postTransformers: [],
},
);
@@ -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-*'],
};
@@ -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();
});
});
@@ -0,0 +1,34 @@
/*
* 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.
*/
// @ts-ignore
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;
};
};
@@ -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',
];
+20 -6
View File
@@ -18,22 +18,36 @@ import transformer from '../reader/transformers';
import type { Transformer } from '../reader/transformers';
export type CreateTestShadowDomOptions = {
transformers: Transformer[];
preTransformers: Transformer[];
postTransformers: Transformer[];
};
export const createTestShadowDom = (
fixture: string,
opts: CreateTestShadowDomOptions = { transformers: [] },
opts: CreateTestShadowDomOptions = {
preTransformers: [],
postTransformers: [],
},
): ShadowRoot => {
const divElement = document.createElement('div');
divElement.attachShadow({ mode: 'open' });
document.body.appendChild(divElement);
const domParser = new DOMParser().parseFromString(fixture, 'text/html');
divElement.shadowRoot?.appendChild(domParser.documentElement);
// Transformers before the UI is rendered
let dom: Element | HTMLElement = new DOMParser().parseFromString(
fixture,
'text/html',
).documentElement;
if (opts.preTransformers) {
dom = transformer(dom, opts.preTransformers);
}
if (opts.transformers) {
transformer(divElement.shadowRoot!.children[0], opts.transformers);
// Mount the UI
divElement.shadowRoot?.appendChild(dom);
// Transformers after the UI is rendered
if (opts.postTransformers) {
transformer(dom, opts.postTransformers);
}
return divElement.shadowRoot!;
+25 -1
View File
@@ -3960,6 +3960,13 @@
"@types/node" "*"
rollup "^0.63.4"
"@types/sanitize-html@^1.23.3":
version "1.23.3"
resolved "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-1.23.3.tgz#26527783aba3bf195ad8a3c3e51bd3713526fc0d"
integrity sha512-Isg8N0ifKdDq6/kaNlIcWfapDXxxquMSk2XC5THsOICRyOIhQGds95XH75/PL/g9mExi4bL8otIqJM/Wo96WxA==
dependencies:
htmlparser2 "^4.1.0"
"@types/serve-static@*":
version "1.13.3"
resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1"
@@ -10031,7 +10038,7 @@ htmlparser2@^3.3.0:
inherits "^2.0.1"
readable-stream "^3.1.1"
htmlparser2@^4.0:
htmlparser2@^4.0, htmlparser2@^4.1.0:
version "4.1.0"
resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78"
integrity sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q==
@@ -16537,6 +16544,18 @@ sane@^4.0.3:
minimist "^1.1.1"
walker "~1.0.5"
sanitize-html@^1.27.0:
version "1.27.0"
resolved "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.27.0.tgz#42104a2d59f1a48b616b5165ad5349824861e580"
integrity sha512-U1btucGeYVpg0GoK43jPpe/bDCV4cBOGuxzv5NBd0bOjyZdMKY0n98S/vNlO1wVwre0VCj8H3hbzE7gD2+RjKA==
dependencies:
chalk "^2.4.1"
htmlparser2 "^4.1.0"
lodash "^4.17.15"
postcss "^7.0.27"
srcset "^2.0.1"
xtend "^4.0.1"
sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4:
version "1.2.4"
resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
@@ -17154,6 +17173,11 @@ sqlite3@^4.2.0:
nan "^2.12.1"
node-pre-gyp "^0.11.0"
srcset@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/srcset/-/srcset-2.0.1.tgz#8f842d357487eb797f413d9c309de7a5149df5ac"
integrity sha512-00kZI87TdRKwt+P8jj8UZxbfp7mK2ufxcIMWvhAOZNJTRROimpHeruWrGvCZneiuVDLqdyHefVp748ECTnyUBQ==
ssh2-streams@~0.4.10:
version "0.4.10"
resolved "https://registry.npmjs.org/ssh2-streams/-/ssh2-streams-0.4.10.tgz#48ef7e8a0e39d8f2921c30521d56dacb31d23a34"