diff --git a/.changeset/neat-insects-ring.md b/.changeset/neat-insects-ring.md new file mode 100644 index 0000000000..a46b6d44e3 --- /dev/null +++ b/.changeset/neat-insects-ring.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Add the techdocs.sanitizer.allowedIframeHosts config. +This config allows all iframes which have the host of the attribute src in the 'allowedIframehosts' list to be displayed in the documentation. diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 8ff486af37..d9a8ffb93a 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -421,3 +421,24 @@ folder (/docs) or replace the content in this file. > on how you have configured your `template.yaml` Done! You now have support for TechDocs in your own software template! + +## how to enable iframes in TechDocs + +Techdocs uses the [DOMPurify](https://github.com/cure53/DOMPurify) to sanitizes +HTML and prevents XSS attacks + +It's possible to allow some iframes based on a list of allowed hosts. To do +this, add the allowed hosts in the `techdocs.sanitizer.allowedIframeHosts` +configuration of your `app-config.yaml` + +E.g. + +```yaml +techdocs: + sanitizer: + allowedIframeHosts: + - drive.google.com +``` + +This way, all iframes where the host of src attribute is in the +`sanitizer.allowedIframeHosts` list will be displayed diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index 45fd524eff..5274a31ed0 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -39,5 +39,16 @@ export interface Config { * @deprecated */ requestUrl?: string; + + sanitizer?: { + /** + * Allows iframe tag only for listed hosts + * Example: + * allowedIframeHosts: ["example.com"] + * this will allow all iframes with the host `example.com` in the src attribute + * @visibility frontend + */ + allowedIframeHosts?: string[]; + }; }; } diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 90a3aa3489..264da9531a 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -28,7 +28,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { Grid, makeStyles, useTheme } from '@material-ui/core'; import { EntityName } from '@backstage/catalog-model'; -import { useApi } from '@backstage/core-plugin-api'; +import { useApi, configApiRef } from '@backstage/core-plugin-api'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { BackstageTheme } from '@backstage/theme'; @@ -137,6 +137,7 @@ export const useTechDocsReaderDom = (entityRef: EntityName): Element | null => { const theme = useTheme(); const techdocsStorageApi = useApi(techdocsStorageApiRef); const scmIntegrationsApi = useApi(scmIntegrationsApiRef); + const techdocsSanitizer = useApi(configApiRef); const { namespace = '', kind = '', name = '' } = entityRef; const { state, path, content: rawPage } = useTechDocsReader(); @@ -170,7 +171,7 @@ export const useTechDocsReaderDom = (entityRef: EntityName): Element | null => { const preRender = useCallback( (rawContent: string, contentPath: string) => transformer(rawContent, [ - sanitizeDOM(), + sanitizeDOM(techdocsSanitizer.getOptionalConfig('techdocs.sanitizer')), addBaseUrl({ techdocsStorageApi, entityId: { @@ -319,6 +320,7 @@ export const useTechDocsReaderDom = (entityRef: EntityName): Element | null => { namespace, scmIntegrationsApi, techdocsStorageApi, + techdocsSanitizer, theme.palette.action.disabledBackground, theme.palette.background.default, theme.palette.background.paper, diff --git a/plugins/techdocs/src/reader/transformers/sanitizeDOM.test.ts b/plugins/techdocs/src/reader/transformers/sanitizeDOM.test.ts index 66019fb74f..697b01259c 100644 --- a/plugins/techdocs/src/reader/transformers/sanitizeDOM.test.ts +++ b/plugins/techdocs/src/reader/transformers/sanitizeDOM.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ConfigReader } from '@backstage/config'; import { createTestShadowDom, FIXTURES } from '../../test-utils'; import { Transformer } from './index'; import { sanitizeDOM } from './sanitizeDOM'; @@ -111,6 +112,111 @@ describe('sanitizeDOM', () => { 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; diff --git a/plugins/techdocs/src/reader/transformers/sanitizeDOM.ts b/plugins/techdocs/src/reader/transformers/sanitizeDOM.ts index 73c3bd2237..da4bd793be 100644 --- a/plugins/techdocs/src/reader/transformers/sanitizeDOM.ts +++ b/plugins/techdocs/src/reader/transformers/sanitizeDOM.ts @@ -34,14 +34,51 @@ export const safeLinksHook = (node: Element) => { 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 = (): 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: ['link'], + ADD_TAGS: addTags, FORBID_TAGS: ['style'], WHOLE_DOCUMENT: true, RETURN_DOM: true,