feat(sanitizer): add support for allowlisting additional URI protocols

Signed-off-by: Nick Hudkins <nick@nickhudkins.com>
This commit is contained in:
Nick Hudkins
2025-07-17 12:39:23 -04:00
parent e656551f8f
commit cb0541f294
5 changed files with 87 additions and 0 deletions
@@ -28,6 +28,7 @@ const configApiMock: ConfigApi = new ConfigReader({
sanitizer: {
allowedCustomElementTagNameRegExp: '^backstage-',
allowedCustomElementAttributeNameRegExp: 'attribute1|attribute2',
additionalAllowedURIProtocols: ['permitted'],
},
},
});
@@ -39,6 +40,28 @@ const wrapper: FC<PropsWithChildren<{}>> = ({ children }) => (
);
describe('Transformers > Html > Sanitizer Custom Elements', () => {
it('allows additional protocols in URIs when provided via config', async () => {
const { result } = renderHook(() => useSanitizerTransformer(), { wrapper });
const dirtyDom = document.createElement('html');
const dirtyHTML = `
<body>
<a href="permitted:mcp/install">Yep</a>
<a href="nope://not-allowed">Nope</a>
<a href="https://example.com">Example</a>
</body>`;
dirtyDom.innerHTML = dirtyHTML;
const clearDom = await result.current(dirtyDom); // calling html transformer
const elements = Array.from(
clearDom.querySelectorAll<HTMLAnchorElement>('body > a'),
);
expect(elements).toHaveLength(3);
expect(elements[0].getAttribute('href')).toEqual('permitted:mcp/install');
expect(elements[1].getAttribute('href')).toBeNull();
expect(elements[2].getAttribute('href')).toEqual('https://example.com');
});
it('should return a function that allows custom elements matching the pattern in the given dom element', async () => {
const { result } = renderHook(() => useSanitizerTransformer(), { wrapper });
@@ -78,6 +78,35 @@ export const useSanitizerTransformer = (): Transformer => {
const attributeNameCheck = config?.getOptionalString(
'allowedCustomElementAttributeNameRegExp',
);
const additionalAllowedURIProtocols =
config?.getOptionalStringArray('additionalAllowedURIProtocols') || [];
// Define allowed URI protocols, including any additional ones from the config.
// The default protocols are based on the DOMPurify defaults.
const allowedURIProtocols = [
'callto',
'cid',
'ftp',
'ftps',
'http',
'https',
'mailto',
'matrix',
'sms',
'tel',
'xmpp',
...additionalAllowedURIProtocols,
].filter(Boolean);
const allowedURIRegExp = new RegExp(
// This regex is not exposed by DOMPurify, so we need to define it ourselves.
// It is possible for this to drift from the default in future versions of DOMPurify.
// See: https://raw.githubusercontent.com/cure53/DOMPurify/master/src/regexp.ts
`^(?:${allowedURIProtocols.join(
'|',
)}:|[^a-z]|[a-z+.-]+(?:[^a-z+.\\-:]|$))`,
'i',
);
// using outerHTML as we want to preserve the html tag attributes (lang)
return DOMPurify.sanitize(dom.outerHTML, {
@@ -86,6 +115,7 @@ export const useSanitizerTransformer = (): Transformer => {
ADD_ATTR: ['http-equiv', 'content', 'dominant-baseline'],
WHOLE_DOCUMENT: true,
RETURN_DOM: true,
ALLOWED_URI_REGEXP: allowedURIRegExp,
CUSTOM_ELEMENT_HANDLING: {
tagNameCheck: tagNameCheck ? new RegExp(tagNameCheck) : undefined,
attributeNameCheck: attributeNameCheck