diff --git a/.changeset/red-fishes-press.md b/.changeset/red-fishes-press.md
new file mode 100644
index 0000000000..8493ddf446
--- /dev/null
+++ b/.changeset/red-fishes-press.md
@@ -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.
diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md
index fd4d208b74..f8be6c9dd5 100644
--- a/docs/features/techdocs/how-to-guides.md
+++ b/docs/features/techdocs/how-to-guides.md
@@ -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 `` 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).
diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts
index 0df852e490..6103f273b5 100644
--- a/plugins/techdocs/config.d.ts
+++ b/plugins/techdocs/config.d.ts
@@ -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 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
+ * @visibility frontend
+ */
+ allowedCustomElementAttributeNameRegExp: string;
};
};
}
diff --git a/plugins/techdocs/src/reader/transformers/html/transformer.sanitizer.test.tsx b/plugins/techdocs/src/reader/transformers/html/transformer.sanitizer.test.tsx
new file mode 100644
index 0000000000..a26757764c
--- /dev/null
+++ b/plugins/techdocs/src/reader/transformers/html/transformer.sanitizer.test.tsx
@@ -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> = ({ children }) => (
+
+ {children}
+
+);
+
+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 = `
+
+
+
+ `;
+ const clearDom = await result.current(dirtyDom); // calling html transformer
+
+ const elements = Array.from(
+ clearDom.querySelectorAll('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 = `
+
+
+
+
+ `;
+ const clearDom = await result.current(dirtyDom); // calling html transformer
+
+ const elements = Array.from(
+ clearDom.querySelectorAll('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 = `
+
+
+
+
+ `;
+ const clearDom = await result.current(dirtyDom); // calling html transformer
+
+ const elements = Array.from(
+ clearDom.querySelectorAll('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);
+ });
+});
diff --git a/plugins/techdocs/src/reader/transformers/html/transformer.ts b/plugins/techdocs/src/reader/transformers/html/transformer.ts
index 4938a36648..2004f941e3 100644
--- a/plugins/techdocs/src/reader/transformers/html/transformer.ts
+++ b/plugins/techdocs/src/reader/transformers/html/transformer.ts
@@ -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],