From a723b8a1e4baf2c9ebb1fc1ec08454433ab4f63f Mon Sep 17 00:00:00 2001 From: Zach Callahan Date: Mon, 5 Jan 2026 16:17:25 -0600 Subject: [PATCH] refactor(ui): use rehype-sanitize for gfm sanitization (#31770) Signed-off-by: Zach Callahan --- .changeset/few-teams-punch.md | 5 + packages/core-components/package.json | 3 + .../MarkdownContent.stories.tsx | 27 ++++ .../MarkdownContent/MarkdownContent.test.tsx | 48 ++++++ .../MarkdownContent/MarkdownContent.tsx | 28 ++++ yarn.lock | 142 ++++++++++++++++++ 6 files changed, 253 insertions(+) create mode 100644 .changeset/few-teams-punch.md diff --git a/.changeset/few-teams-punch.md b/.changeset/few-teams-punch.md new file mode 100644 index 0000000000..4443c43170 --- /dev/null +++ b/.changeset/few-teams-punch.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +The MarkdownContent component now handles HTML content the same way as GitHub when rendering GitHub-flavored Markdown diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 536f0ee9f0..72aa753bed 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -77,6 +77,7 @@ "linkify-react": "4.3.2", "linkifyjs": "4.3.2", "lodash": "^4.17.21", + "parse5": "^6.0.0", "pluralize": "^8.0.0", "qs": "^6.9.4", "rc-progress": "3.5.1", @@ -90,6 +91,8 @@ "react-use": "^17.3.2", "react-virtualized-auto-sizer": "^1.0.11", "react-window": "^1.8.6", + "rehype-raw": "^6.0.0", + "rehype-sanitize": "^5.0.0", "remark-gfm": "^3.0.1", "zen-observable": "^0.10.0", "zod": "^3.22.4" diff --git a/packages/core-components/src/components/MarkdownContent/MarkdownContent.stories.tsx b/packages/core-components/src/components/MarkdownContent/MarkdownContent.stories.tsx index b6770e7d37..719f9088ba 100644 --- a/packages/core-components/src/components/MarkdownContent/MarkdownContent.stories.tsx +++ b/packages/core-components/src/components/MarkdownContent/MarkdownContent.stories.tsx @@ -45,6 +45,29 @@ const markdownGithubFlavored = '* [ ] to do\n' + '* [x] done'; +const markdownGithubFlavoredWithHTML = + '# GFM with HTML\n' + + '\n' + + 'This is a paragraph with bold text and italic text.\n' + + '\n' + + '
\n' + + '\n' + + 'Here is a list:\n' + + '\n' + + '\n' + + '\n' + + 'And a code block:\n' + + '\n' + + '
\n' +
+  'function greet() {\n' +
+  '  console.log("Hello, world!");\n' +
+  '}\n' +
+  '
\n'; + const markdown = '# Choreas Iovis\n' + '\n' + @@ -117,3 +140,7 @@ export const MarkdownContentCommonMark = () => ( export const MarkdownContentGithubFlavoredCommonMark = () => ( ); + +export const MarkdownContentGithubFlavoredWithHTML = () => ( + +); diff --git a/packages/core-components/src/components/MarkdownContent/MarkdownContent.test.tsx b/packages/core-components/src/components/MarkdownContent/MarkdownContent.test.tsx index 6f20f776e3..4dd40edcbf 100644 --- a/packages/core-components/src/components/MarkdownContent/MarkdownContent.test.tsx +++ b/packages/core-components/src/components/MarkdownContent/MarkdownContent.test.tsx @@ -163,4 +163,52 @@ describe('', () => { 'the-fitnessgram-pacer-test-is-a-multistage-aerobic-capacity-test', ); }); + + it('render MarkdownContent component with br tags for new lines in GFM dialect', async () => { + await renderInTestApp( + , + ); + + const line1 = screen.getByText(/Line 1/); + const line2 = screen.getByText(/Line 2/); + const line3 = screen.getByText(/Line 3/); + + expect(line1.nextSibling?.nodeName).toBe('BR'); + expect(line2.previousSibling?.nodeName).toBe('BR'); + expect(line2.nextSibling?.nodeName).toBe('BR'); + expect(line3.previousSibling?.nodeName).toBe('BR'); + }); + + it('render MarkdownContent component without allowing inline styles in GFM dialect', async () => { + await renderInTestApp( + , + ); + + const divElement = screen.getByText( + 'This is a custom HTML block with inline styles.', + ); + expect(divElement).toBeInTheDocument(); + expect(divElement).not.toHaveStyle('color: blue'); + expect(divElement).not.toHaveStyle('border: 1px solid black'); + expect(divElement).not.toHaveStyle('padding: 10px'); + }); + + it('render MarkdownContent component without disallowed elements in GFM dialect', async () => { + const { container } = await renderInTestApp( + , + ); + + expect(screen.getByText('Safe Content')).toBeInTheDocument(); + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('style')).toBeNull(); + }); }); diff --git a/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx b/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx index 5ad23cce09..6d978ebb6f 100644 --- a/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx +++ b/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx @@ -20,6 +20,9 @@ import gfm from 'remark-gfm'; import { Children, createElement } from 'react'; import { CodeSnippet } from '../CodeSnippet'; import { HeadingProps } from 'react-markdown/lib/ast-to-react'; +import rehypeRaw from 'rehype-raw'; +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; +import type { PluggableList } from 'react-markdown/lib/react-markdown'; export type MarkdownContentClassKey = 'markdown'; @@ -108,6 +111,30 @@ const components: Options['components'] = { h6: headingRenderer, }; +const gfmRehypePlugins: PluggableList = [ + [ + rehypeRaw, + { + tagFiter: true, + }, + ], + [ + rehypeSanitize, + { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + code: [ + ...(defaultSchema.attributes?.code ?? []), + // for syntax highlighting classes in code blocks + // breaks the codesnippet component override above if omitted + ['className'], + ], + }, + }, + ], +]; + /** * Renders markdown with the default dialect {@link https://github.github.com/gfm/ | gfm - GitHub flavored Markdown} to backstage theme styled HTML. * @@ -127,6 +154,7 @@ export function MarkdownContent(props: Props) { return (