techdocs(sanitizer): adds iframe support (#8237)
Signed-off-by: dnsaoki2 <dnsaoki@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Vendored
+11
@@ -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[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<BackstageTheme>();
|
||||
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,
|
||||
|
||||
@@ -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 = `
|
||||
<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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user