From 067fdcd0a5b6b63a689f9fe4fa322c690ed263fb Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Tue, 5 Aug 2025 11:18:09 +0200 Subject: [PATCH] techdocs: Disallow javascript URLs Signed-off-by: Joshua Rogers --- .changeset/great-adults-stare.md | 5 ++++ .../transformers/rewriteDocLinks.test.ts | 27 +++++++++++++++++++ .../reader/transformers/rewriteDocLinks.ts | 11 ++++++++ 3 files changed, 43 insertions(+) create mode 100644 .changeset/great-adults-stare.md diff --git a/.changeset/great-adults-stare.md b/.changeset/great-adults-stare.md new file mode 100644 index 0000000000..05d196ac62 --- /dev/null +++ b/.changeset/great-adults-stare.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +Ensure that techdocs rewritten URLs do not contain potentially dangerous javascript: URLs. diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts index 66fc5f29c1..864129164c 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts @@ -71,6 +71,33 @@ describe('rewriteDocLinks', () => { expect(getSample(shadowDom, 'a', 'href')).toEqual([]); expect(shadowDom.innerHTML).toContain(expectedText); }); + + it('should rewrite javascript hrefs as text', async () => { + const samples: Array<[string, string]> = [ + // eslint-disable-next-line no-script-url + ['javascript:alert(1)', 'JS 1'], + [' javascript:alert(2)', 'JS 2 (leading space)'], + ['\n\tjavascript:alert(3)', 'JS 3 (whitespace)'], + // eslint-disable-next-line no-script-url + ['JaVaScRiPt:alert(4)', 'JS 4 (mixed case)'], + ['javascript:alert(5)', 'JS 5 (entity-encoded colon)'], + ]; + + const html = samples + .map(([href, text]) => `${text}`) + .join('\n'); + + const shadowDom = await createTestShadowDom(html, { + preTransformers: [rewriteDocLinks()], + postTransformers: [], + }); + + // There should be no tags, but the link text should remain. + expect(getSample(shadowDom, 'a', 'href')).toEqual([]); + for (const [, text] of samples) { + expect(shadowDom.innerHTML).toContain(text); + } + }); }); describe('normalizeUrl', () => { diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts index e43dda1815..a1005fe1d6 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -16,6 +16,11 @@ import type { Transformer } from './transformer'; +// See https://github.com/facebook/react/blob/f0cf832e1d0c8544c36aa8b310960885a11a847c/packages/react-dom-bindings/src/shared/sanitizeURL.js +const scriptProtocolPattern = + // eslint-disable-next-line no-control-regex + /^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*\:/i; + export const rewriteDocLinks = (): Transformer => { return dom => { const updateDom = ( @@ -33,6 +38,12 @@ export const rewriteDocLinks = (): Transformer => { } try { + if (scriptProtocolPattern.test(elemAttribute)) { + throw new TypeError( + `Invalid location ref '${elemAttribute}', target is a javascript: URL`, + ); + } + const normalizedWindowLocation = normalizeUrl( window.location.href, );