Add DomPurify sanitizer custom elements configuration (#26989)

* Add DomPurify sanitizer custom elements configuration
---------

Signed-off-by: Harsha Teja Kanna <h7kanna@gmail.com>
This commit is contained in:
Harsha Teja Kanna
2024-10-17 13:21:56 -04:00
committed by GitHub
parent 9a6d61c9c9
commit 4f0cb89c42
5 changed files with 157 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-techdocs': patch
---
Added DomPurify sanitizer configuration for custom elements implementing RFC https://github.com/backstage/backstage/issues/26988.
See https://backstage.io/docs/features/techdocs/how-to-guides#how-to-enable-custom-elements-in-techdocs for how to enable it in the configuration.
+20
View File
@@ -545,6 +545,26 @@ techdocs:
This way, all iframes where the host in the src attribute is in the
`sanitizer.allowedIframeHosts` list will be displayed.
## How to enable custom elements in TechDocs
TechDocs uses the [DOMPurify](https://github.com/cure53/DOMPurify) library to
sanitize HTML and prevent XSS attacks.
It's possible to allow custom elements based on a list of allowed patterns. To do
this, add the allowed elements and attributes in the `techdocs.sanitizer.allowedCustomElementTagNameRegExp`
and `allowedCustomElementAttributeNameRegExp` configuration of your `app-config.yaml`.
For example:
```yaml
techdocs:
sanitizer:
allowedCustomElementTagNameRegExp: '^backstage-',
allowedCustomElementAttributeNameRegExp: 'attribute1|attribute2',
```
This way, custom element like `<backstage-element attribute1="value"></backstage-element>` will be allowed in the result HTML.
## How to render PlantUML diagram in TechDocs
PlantUML allows you to create diagrams from plain text language. Each diagram description begins with the keyword - (@startXYZ and @endXYZ, depending on the kind of diagram). For UML Diagrams, Keywords @startuml & @enduml should be used. Further details for all types of diagrams can be found at [PlantUML Language Reference Guide](https://plantuml.com/guide).
+16
View File
@@ -42,6 +42,22 @@ export interface Config {
* @visibility frontend
*/
allowedIframeHosts?: string[];
/**
* Allows listed custom element tag name regex
* Example:
* allowedCustomElementTagNameRegExp: '^backstage-'
* this will allow all custom elements with tag name matching `^backstage-` like <backstage-custom-element /> etc.
* @visibility frontend
*/
allowedCustomElementTagNameRegExp: string;
/**
* Allows listed custom element attribute name regex
* Example:
* allowedCustomElementAttributeNameRegExp: 'attribute1|attribute2'
* this will allow all custom element attributes matching `attribute1` or `attribute2` like <backstage-custom-element attribute1="yes" attribute2/>
* @visibility frontend
*/
allowedCustomElementAttributeNameRegExp: string;
};
};
}
@@ -0,0 +1,102 @@
/*
* 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, PropsWithChildren } from 'react';
import { renderHook } from '@testing-library/react';
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: {
allowedCustomElementTagNameRegExp: '^backstage-',
allowedCustomElementAttributeNameRegExp: 'attribute1|attribute2',
},
},
});
const wrapper: FC<PropsWithChildren<{}>> = ({ children }) => (
<TestApiProvider apis={[[configApiRef, configApiMock]]}>
{children}
</TestApiProvider>
);
describe('Transformers > Html > Sanitizer Custom Elements', () => {
it('should return a function that allows custom elements matching the pattern in the given dom element', async () => {
const { result } = renderHook(() => useSanitizerTransformer(), { wrapper });
const dirtyDom = document.createElement('html');
dirtyDom.innerHTML = `
<body>
<backstage-element attribute1="test" attribute2></backstage-element>
</body>
`;
const clearDom = await result.current(dirtyDom); // calling html transformer
const elements = Array.from(
clearDom.querySelectorAll<HTMLElement>('body > backstage-element'),
);
expect(elements).toHaveLength(1);
expect(elements[0].hasAttribute('attribute1')).toEqual(true);
expect(elements[0].hasAttribute('attribute2')).toEqual(true);
});
it('should return a function that removes custom elements not matching the pattern in the given dom element', async () => {
const { result } = renderHook(() => useSanitizerTransformer(), { wrapper });
const dirtyDom = document.createElement('html');
dirtyDom.innerHTML = `
<body>
<backstage-element attribute1="test" attribute2></backstage-element>
<invalid-element attribute1="test" attribute2></invalid-element>
</body>
`;
const clearDom = await result.current(dirtyDom); // calling html transformer
const elements = Array.from(
clearDom.querySelectorAll<HTMLElement>('body > backstage-element'),
);
expect(elements).toHaveLength(1);
expect(elements[0].hasAttribute('attribute1')).toEqual(true);
expect(elements[0].hasAttribute('attribute2')).toEqual(true);
});
it('should return a function that removes custom element attributes not matching the pattern in the given dom element', async () => {
const { result } = renderHook(() => useSanitizerTransformer(), { wrapper });
const dirtyDom = document.createElement('html');
dirtyDom.innerHTML = `
<body>
<backstage-element attribute1="test" attribute2></backstage-element>
<backstage-element attribute3="test" attribute4></backstage-element>
</body>
`;
const clearDom = await result.current(dirtyDom); // calling html transformer
const elements = Array.from(
clearDom.querySelectorAll<HTMLElement>('body > backstage-element'),
);
expect(elements).toHaveLength(2);
expect(elements[0].hasAttribute('attribute1')).toEqual(true);
expect(elements[0].hasAttribute('attribute2')).toEqual(true);
expect(elements[1].hasAttribute('attribute3')).toEqual(false);
expect(elements[1].hasAttribute('attribute4')).toEqual(false);
});
});
@@ -72,6 +72,13 @@ export const useSanitizerTransformer = (): Transformer => {
}
});
const tagNameCheck = config?.getOptionalString(
'allowedCustomElementTagNameRegExp',
);
const attributeNameCheck = config?.getOptionalString(
'allowedCustomElementAttributeNameRegExp',
);
// using outerHTML as we want to preserve the html tag attributes (lang)
return DOMPurify.sanitize(dom.outerHTML, {
ADD_TAGS: tags,
@@ -79,6 +86,12 @@ export const useSanitizerTransformer = (): Transformer => {
ADD_ATTR: ['http-equiv', 'content'],
WHOLE_DOCUMENT: true,
RETURN_DOM: true,
CUSTOM_ELEMENT_HANDLING: {
tagNameCheck: tagNameCheck ? new RegExp(tagNameCheck) : undefined,
attributeNameCheck: attributeNameCheck
? new RegExp(attributeNameCheck)
: undefined,
},
});
},
[config],