Merge pull request #11647 from backstage/camilal/covert-sanitizer-transformer-into-hook
[Techdocs]Convert `sanitizeDOM` to hook
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Convert `sanitizeDOM` transformer to hook as part of code readability improvements in dom file.
|
||||
@@ -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<BackstageTheme>();
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 }) => (
|
||||
<TestApiProvider apis={[[configApiRef, configApiMock]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
);
|
||||
|
||||
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 = `
|
||||
<head>
|
||||
<link src="http://unsafe-host.com"/>
|
||||
<link rel="stylesheet" href="assets/stylesheets/main.50e68009.min.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,700%7CRoboto+Mono&display=fallback">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
</head>
|
||||
`;
|
||||
const clearDom = await result.current(dirtyDom); // calling html transformer
|
||||
|
||||
const links = Array.from(
|
||||
clearDom.querySelectorAll<HTMLLinkElement>('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 = `
|
||||
<body>
|
||||
<iframe src="invalid"></iframe>
|
||||
<iframe src="http://unsafe-host.com"></iframe>
|
||||
<iframe src="https://docs.google.com/document/d/1fQ7SayGdQ7Sa"></iframe>
|
||||
</body>
|
||||
`;
|
||||
const clearDom = await result.current(dirtyDom); // calling html transformer
|
||||
|
||||
const iframes = Array.from(
|
||||
clearDom.querySelectorAll<HTMLIFrameElement>('body > iframe'),
|
||||
);
|
||||
|
||||
expect(iframes).toHaveLength(1);
|
||||
expect(iframes[0].src).toMatch('docs.google.com');
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = `
|
||||
<html>
|
||||
<head>
|
||||
<style>* {color: #f0f;}</style>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</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 = `
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</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 = `
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="https://example.com?test=1"></iframe>
|
||||
<iframe src="https://forbidden.com?test=1"></iframe>
|
||||
</body>
|
||||
</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 = `
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="https://example.com?test=1"></iframe>
|
||||
<iframe src="https://forbidden.com?test=1"></iframe>
|
||||
</body>
|
||||
</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 = `
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="invalid.md"></iframe>
|
||||
</body>
|
||||
</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: "'<p>Hello world!</p>'", allowed: false },
|
||||
{ key: 'onload', value: "'alert(1)'", allowed: false },
|
||||
])('check if the iframe has the attribute %p', async attr => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="https://example.com?test=1" ${attr.key}=${attr.value}></iframe>
|
||||
</body>
|
||||
</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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user