diff --git a/.changeset/techdocs-vans-run.md b/.changeset/techdocs-vans-run.md new file mode 100644 index 0000000000..2e59da8ad5 --- /dev/null +++ b/.changeset/techdocs-vans-run.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Convert `sanitizeDOM` transformer to hook as part of code readability improvements in dom file. diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx index 87331c2e46..6a825a4788 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx @@ -22,7 +22,7 @@ import { lighten, alpha } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; import { CompoundEntityRef } from '@backstage/catalog-model'; -import { useApi, configApiRef } from '@backstage/core-plugin-api'; +import { useApi } from '@backstage/core-plugin-api'; import { SidebarPinStateContext } from '@backstage/core-components'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; @@ -42,11 +42,11 @@ import { onCssReady, removeMkdocsHeader, rewriteDocLinks, - sanitizeDOM, simplifyMkdocsFooter, scrollIntoAnchor, transform as transformer, copyToClipboard, + useSanitizerTransformer, } from '../../transformers'; const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)'; @@ -77,8 +77,8 @@ export const useTechDocsReaderDom = ( const sidebar = useSidebar(); const theme = useTheme(); const isMobileMedia = useMediaQuery(MOBILE_MEDIA_QUERY); + const sanitizerTransformer = useSanitizerTransformer(); - const configApi = useApi(configApiRef); const techdocsStorageApi = useApi(techdocsStorageApiRef); const scmIntegrationsApi = useApi(scmIntegrationsApiRef); @@ -146,7 +146,7 @@ export const useTechDocsReaderDom = ( const preRender = useCallback( (rawContent: string, contentPath: string) => transformer(rawContent, [ - sanitizeDOM(configApi.getOptionalConfig('techdocs.sanitizer')), + sanitizerTransformer, addBaseUrl({ techdocsStorageApi, entityId: entityRef, @@ -696,9 +696,9 @@ export const useTechDocsReaderDom = ( entityRef, theme, sidebar, - configApi, scmIntegrationsApi, techdocsStorageApi, + sanitizerTransformer, ], ); diff --git a/plugins/techdocs/src/reader/transformers/html/hooks/iframes.ts b/plugins/techdocs/src/reader/transformers/html/hooks/iframes.ts new file mode 100644 index 0000000000..25259dbc43 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/html/hooks/iframes.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +/** + * Checks whether a node is iframe or not. + * @param node - can be any element. + * @returns true when node is iframe. + */ +const isIframe = (node: Element) => node.nodeName === 'IFRAME'; + +/** + * Checks whether a iframe is safe or not. + * @param node - is an iframe element. + * @param hosts - list of allowed hosts. + * @returns true when iframe is included in hosts. + */ +const isSafe = (node: Element, hosts: string[]) => { + const src = node.getAttribute('src') || ''; + try { + const { host } = new URL(src); + return hosts.includes(host); + } catch { + return false; + } +}; + +/** + * Returns a function that removes unsafe iframe nodes. + * @param node - can be any element. + * @param hosts - list of allowed hosts. + */ +export const removeUnsafeIframes = (hosts: string[]) => (node: Element) => { + if (isIframe(node) && !isSafe(node, hosts)) { + node.remove(); + } + return node; +}; diff --git a/plugins/techdocs/src/reader/transformers/html/hooks/index.ts b/plugins/techdocs/src/reader/transformers/html/hooks/index.ts new file mode 100644 index 0000000000..a4356db2e9 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/html/hooks/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +export { removeUnsafeLinks } from './links'; +export { removeUnsafeIframes } from './iframes'; diff --git a/plugins/techdocs/src/reader/transformers/html/hooks/links.ts b/plugins/techdocs/src/reader/transformers/html/hooks/links.ts new file mode 100644 index 0000000000..38f775cfbe --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/html/hooks/links.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +const MKDOCS_CSS = /main\.[A-Fa-f0-9]{8}\.min\.css$/; +const GOOGLE_FONTS = /^https:\/\/fonts\.googleapis\.com/; +const GSTATIC_FONTS = /^https:\/\/fonts\.gstatic\.com/; + +/** + * Checks whether a node is link or not. + * @param node - can be any element. + * @returns true when node is link. + */ +const isLink = (node: Element) => node.nodeName === 'LINK'; + +/** + * Checks whether a link is safe or not. + * @param node - is an link element. + * @returns true when link is mkdocs css, google fonts or gstatic fonts. + */ +const isSafe = (node: Element) => { + const href = node?.getAttribute('href') || ''; + const isMkdocsCss = href.match(MKDOCS_CSS); + const isGoogleFonts = href.match(GOOGLE_FONTS); + const isGstaticFonts = href.match(GSTATIC_FONTS); + return isMkdocsCss || isGoogleFonts || isGstaticFonts; +}; + +/** + * Function that removes unsafe link nodes. + * @param node - can be any element. + * @param hosts - list of allowed hosts. + */ +export const removeUnsafeLinks = (node: Element) => { + if (isLink(node) && !isSafe(node)) { + node.remove(); + } + return node; +}; diff --git a/plugins/techdocs/src/reader/transformers/html/index.ts b/plugins/techdocs/src/reader/transformers/html/index.ts new file mode 100644 index 0000000000..4b8bd06798 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/html/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +export { useSanitizerTransformer } from './transformer'; diff --git a/plugins/techdocs/src/reader/transformers/html/transformer.test.tsx b/plugins/techdocs/src/reader/transformers/html/transformer.test.tsx new file mode 100644 index 0000000000..dc4eeb85be --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/html/transformer.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 React, { FC } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { ConfigReader } from '@backstage/core-app-api'; +import { ConfigApi, configApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; + +import { useSanitizerTransformer } from './transformer'; + +const configApiMock: ConfigApi = new ConfigReader({ + techdocs: { + sanitizer: { + allowedIframeHosts: ['docs.google.com'], + }, + }, +}); + +const wrapper: FC = ({ children }) => ( + + {children} + +); + +describe('Transformers > Html', () => { + it('should return a function that removes unsafe links from a given dom element', async () => { + const { result } = renderHook(() => useSanitizerTransformer(), { wrapper }); + + const dirtyDom = document.createElement('html'); + dirtyDom.innerHTML = ` + + + + + + + `; + const clearDom = await result.current(dirtyDom); // calling html transformer + + const links = Array.from( + clearDom.querySelectorAll('head > link'), + ); + expect(links).toHaveLength(3); + expect(links[0].href).toMatch('assets/stylesheets/main.50e68009.min.css'); + expect(links[1].href).toMatch('https://fonts.googleapis.com'); + expect(links[2].href).toMatch('https://fonts.gstatic.com'); + }); + + it('should return a function that removes unsafe iframes from a given dom element', async () => { + const { result } = renderHook(() => useSanitizerTransformer(), { wrapper }); + + const dirtyDom = document.createElement('html'); + dirtyDom.innerHTML = ` + + + + + + `; + const clearDom = await result.current(dirtyDom); // calling html transformer + + const iframes = Array.from( + clearDom.querySelectorAll('body > iframe'), + ); + + expect(iframes).toHaveLength(1); + expect(iframes[0].src).toMatch('docs.google.com'); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/html/transformer.ts b/plugins/techdocs/src/reader/transformers/html/transformer.ts new file mode 100644 index 0000000000..a197a2d4b7 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/html/transformer.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 DOMPurify from 'dompurify'; +import { useMemo, useCallback } from 'react'; + +import { useApi, configApiRef } from '@backstage/core-plugin-api'; + +import { Transformer } from '../transformer'; +import { removeUnsafeLinks, removeUnsafeIframes } from './hooks'; + +/** + * Returns html sanitizer configuration + */ +const useSanitizerConfig = () => { + const configApi = useApi(configApiRef); + + return useMemo(() => { + return configApi.getOptionalConfig('techdocs.sanitizer'); + }, [configApi]); +}; + +/** + * Returns a transformer that sanitizes the dom's internal html. + */ +export const useSanitizerTransformer = (): Transformer => { + const config = useSanitizerConfig(); + + return useCallback( + async (dom: Element) => { + const hosts = config?.getOptionalStringArray('allowedIframeHosts'); + + DOMPurify.addHook('beforeSanitizeElements', removeUnsafeLinks); + const tags = ['link']; + + if (hosts) { + tags.push('iframe'); + DOMPurify.addHook('beforeSanitizeElements', removeUnsafeIframes(hosts)); + } + + return DOMPurify.sanitize(dom.innerHTML, { + ADD_TAGS: tags, + FORBID_TAGS: ['style'], + WHOLE_DOCUMENT: true, + RETURN_DOM: true, + }); + }, + [config], + ); +}; diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index dcd79180a7..bc72f2d78f 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from './html'; export * from './addBaseUrl'; export * from './addGitFeedbackLink'; export * from './addSidebarToggle'; @@ -23,7 +24,6 @@ export * from './copyToClipboard'; export * from './removeMkdocsHeader'; export * from './simplifyMkdocsFooter'; export * from './onCssReady'; -export * from './sanitizeDOM'; export * from './injectCss'; export * from './scrollIntoAnchor'; export * from './transformer'; diff --git a/plugins/techdocs/src/reader/transformers/sanitizeDOM.test.ts b/plugins/techdocs/src/reader/transformers/sanitizeDOM.test.ts deleted file mode 100644 index 697b01259c..0000000000 --- a/plugins/techdocs/src/reader/transformers/sanitizeDOM.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * 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 { ConfigReader } from '@backstage/config'; -import { createTestShadowDom, FIXTURES } from '../../test-utils'; -import { Transformer } from './index'; -import { sanitizeDOM } from './sanitizeDOM'; - -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', async () => { - const shadowDom = await createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE); - - expect(shadowDom.querySelectorAll('script').length).toBeGreaterThan(0); - }); - - it('does not contain a script tag', async () => { - const shadowDom = await createTestShadowDom( - FIXTURES.FIXTURE_STANDARD_PAGE, - { - preTransformers: [sanitizeDOM()], - postTransformers: [], - }, - ); - - expect(shadowDom.querySelectorAll('script').length).toBe(0); - }); - - it('contains link with a onClick attribute', async () => { - const shadowDom = await 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', async () => { - const shadowDom = await createTestShadowDom( - FIXTURES.FIXTURE_STANDARD_PAGE, - { - preTransformers: [sanitizeDOM()], - postTransformers: [], - }, - ); - - expect( - shadowDom.querySelector('#test-malicious-link')?.hasAttribute('onclick'), - ).toBeFalsy(); - }); - - it('removes style tags', async () => { - const html = ` - - - - - - - - `; - - const shadowDom = await createTestShadowDom(html, { - preTransformers: [sanitizeDOM()], - postTransformers: [], - }); - - expect(shadowDom.querySelectorAll('style').length).toEqual(0); - }); - - it('does not remove link tags', async () => { - const html = ` - - - - - - - - `; - - const shadowDom = await createTestShadowDom(html, { - preTransformers: [sanitizeDOM()], - postTransformers: [], - }); - - expect(shadowDom.querySelectorAll('link').length).toEqual(1); - }); - - it('render iframe where src host is in allowedIframeHosts', async () => { - const html = ` - - - - - - - - - - `; - const config = new ConfigReader({ - allowedIframeHosts: ['example.com'], - }); - const shadowDom = await createTestShadowDom(html, { - preTransformers: [sanitizeDOM(config)], - postTransformers: [], - }); - expect(shadowDom.querySelectorAll('link').length).toEqual(1); - expect(shadowDom.querySelectorAll('iframe').length).toEqual(1); - expect(shadowDom.querySelectorAll('iframe')[0].getAttribute('src')).toBe( - 'https://example.com?test=1', - ); - }); - - it('should remove all iframes without allowedIframeHosts', async () => { - const html = ` - - - - - - - - - - `; - const config = new ConfigReader({}); - const shadowDom = await createTestShadowDom(html, { - preTransformers: [sanitizeDOM(config)], - postTransformers: [], - }); - expect(shadowDom.querySelectorAll('link').length).toEqual(1); - expect(shadowDom.querySelectorAll('iframe').length).toEqual(0); - }); - - it('should remove iframe with invalid url in src', async () => { - const html = ` - - - - - - - - - `; - const config = new ConfigReader({ - allowedIframeHosts: ['example.com'], - }); - const shadowDom = await createTestShadowDom(html, { - preTransformers: [sanitizeDOM(config)], - postTransformers: [], - }); - expect(shadowDom.querySelectorAll('link').length).toEqual(1); - expect(shadowDom.querySelectorAll('iframe').length).toEqual(0); - }); - - test.each([ - { key: 'allow', value: '"camera \'none\'"', allowed: false }, - { key: 'allowfullscreen', value: true, allowed: false }, - { key: 'allowpaymentrequest', value: true, allowed: false }, - { key: 'height', value: true, allowed: true }, - { key: 'loading', value: "'lazy'", allowed: true }, - { key: 'name', value: "'example'", allowed: true }, - { key: 'referrerpolicy', value: "'no-referrer'", allowed: false }, - { key: 'sandbox', value: "'allow-forms'", allowed: false }, - { key: 'srcdoc', value: "'

Hello world!

'", allowed: false }, - { key: 'onload', value: "'alert(1)'", allowed: false }, - ])('check if the iframe has the attribute %p', async attr => { - const html = ` - - - - - - - - - `; - const config = new ConfigReader({ - allowedIframeHosts: ['example.com'], - }); - const shadowDom = await createTestShadowDom(html, { - preTransformers: [sanitizeDOM(config)], - postTransformers: [], - }); - expect(shadowDom.querySelectorAll('link').length).toEqual(1); - expect(shadowDom.querySelectorAll('iframe').length).toEqual(1); - expect(shadowDom.querySelectorAll('iframe')[0].hasAttribute(attr.key)).toBe( - attr.allowed, - ); - }); - - describe('safe head links', () => { - let shadowDom: ShadowRoot; - - beforeEach(async () => { - shadowDom = await createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { - preTransformers: [sanitizeDOM()], - postTransformers: [], - }); - }); - - it('should not sanitize the techdocs css', async () => { - const techdocsCss = shadowDom.querySelector( - 'link[href$="main.fe0cca5b.min.css"]', - ); - const rel = techdocsCss!.getAttribute('rel'); - expect(rel).toBe('stylesheet'); - }); - - it('should not sanitize google fonts', async () => { - const googleFonts = shadowDom.querySelector( - 'link[href^="https://fonts.googleapis.com"]', - ); - const rel = googleFonts!.getAttribute('rel'); - expect(rel).toBe('stylesheet'); - }); - - it('should not sanitize gstatic fonts', async () => { - const gstaticFonts = shadowDom.querySelector( - 'link[href^="https://fonts.gstatic.com"]', - ); - const rel = gstaticFonts!.getAttribute('rel'); - expect(rel).toBe('preconnect'); - }); - }); -}); diff --git a/plugins/techdocs/src/reader/transformers/sanitizeDOM.ts b/plugins/techdocs/src/reader/transformers/sanitizeDOM.ts deleted file mode 100644 index da4bd793be..0000000000 --- a/plugins/techdocs/src/reader/transformers/sanitizeDOM.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * 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. - */ - -const TECHDOCS_CSS = /main\.[A-Fa-f0-9]{8}\.min\.css$/; -const GOOGLE_FONTS = /^https:\/\/fonts\.googleapis\.com/; -const GSTATIC_FONTS = /^https:\/\/fonts\.gstatic\.com/; - -export const safeLinksHook = (node: Element) => { - if (node.nodeName && node.nodeName === 'LINK') { - const href = node.getAttribute('href') || ''; - if (href.match(TECHDOCS_CSS)) { - node.setAttribute('rel', 'stylesheet'); - } - if (href.match(GOOGLE_FONTS)) { - node.setAttribute('rel', 'stylesheet'); - } - if (href.match(GSTATIC_FONTS)) { - node.setAttribute('rel', 'preconnect'); - } - } - return node; -}; - -const filterIframeHook = (allowedIframeHosts: string[]) => (node: Element) => { - if (node.nodeName === 'IFRAME') { - const src = node.getAttribute('src'); - if (!src) { - node.remove(); - return node; - } - - try { - const srcUrl = new URL(src); - const isMatch = allowedIframeHosts.some(host => srcUrl.host === host); - if (!isMatch) { - node.remove(); - } - } catch (error) { - // eslint-disable-next-line no-console - console.warn(`Invalid iframe src, ${error}`); - node.remove(); - } - } - return node; -}; - -import { Config } from '@backstage/config'; -import DOMPurify from 'dompurify'; -import type { Transformer } from './transformer'; - -export const sanitizeDOM = (config?: Config): Transformer => { - const allowedIframeHosts = - config?.getOptionalStringArray('allowedIframeHosts') || []; - - return dom => { - DOMPurify.addHook('afterSanitizeAttributes', safeLinksHook); - const addTags = ['link']; - - if (allowedIframeHosts.length > 0) { - DOMPurify.addHook( - 'beforeSanitizeElements', - filterIframeHook(allowedIframeHosts), - ); - addTags.push('iframe'); - } - - return DOMPurify.sanitize(dom.innerHTML, { - ADD_TAGS: addTags, - FORBID_TAGS: ['style'], - WHOLE_DOCUMENT: true, - RETURN_DOM: true, - }); - }; -};