refactor(ui): use rehype-sanitize for gfm sanitization (#31770)

Signed-off-by: Zach Callahan <zach@zmc.dev>
This commit is contained in:
Zach Callahan
2026-01-05 16:17:25 -06:00
committed by GitHub
parent 332eaf5841
commit a723b8a1e4
6 changed files with 253 additions and 0 deletions
+5
View File
@@ -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
+3
View File
@@ -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"
@@ -45,6 +45,29 @@ const markdownGithubFlavored =
'* [ ] to do\n' +
'* [x] done';
const markdownGithubFlavoredWithHTML =
'# GFM with HTML\n' +
'\n' +
'This is a paragraph with <strong>bold text</strong> and <em>italic text</em>.\n' +
'\n' +
'<br />\n' +
'\n' +
'Here is a list:\n' +
'\n' +
'<ul>\n' +
' <li>First item</li>\n' +
' <li>Second item with <a href="https://example.com">a link</a></li>\n' +
' <li>Third item</li>\n' +
'</ul>\n' +
'\n' +
'And a code block:\n' +
'\n' +
'<pre><code class="language-js">\n' +
'function greet() {\n' +
' console.log("Hello, world!");\n' +
'}\n' +
'</code></pre>\n';
const markdown =
'# Choreas Iovis\n' +
'\n' +
@@ -117,3 +140,7 @@ export const MarkdownContentCommonMark = () => (
export const MarkdownContentGithubFlavoredCommonMark = () => (
<MarkdownContent content={markdownGithubFlavored} dialect="gfm" />
);
export const MarkdownContentGithubFlavoredWithHTML = () => (
<MarkdownContent content={markdownGithubFlavoredWithHTML} dialect="gfm" />
);
@@ -163,4 +163,52 @@ describe('<MarkdownContent />', () => {
'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(
<MarkdownContent
content="<p>Line 1</p><br /><p>Line 2</p><br><p>Line 3</p><br />"
dialect="gfm"
/>,
);
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(
<MarkdownContent
content='<div style="color: blue; border: 1px solid black; padding: 10px;">This is a custom HTML block with inline styles.</div>'
dialect="gfm"
/>,
);
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(
<MarkdownContent
content='<script>alert("XSS Attack!");</script><style>body { background-color: red; }</style><p>Safe Content</p>'
dialect="gfm"
/>,
);
expect(screen.getByText('Safe Content')).toBeInTheDocument();
expect(container.querySelector('script')).toBeNull();
expect(container.querySelector('style')).toBeNull();
});
});
@@ -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 (
<ReactMarkdown
remarkPlugins={dialect === 'gfm' ? [gfm] : []}
rehypePlugins={dialect === 'gfm' ? gfmRehypePlugins : []}
className={`${classes.markdown} ${className ?? ''}`.trim()}
children={content}
components={components}
+142
View File
@@ -3645,6 +3645,7 @@ __metadata:
linkifyjs: "npm:4.3.2"
lodash: "npm:^4.17.21"
msw: "npm:^1.0.0"
parse5: "npm:^6.0.0"
pluralize: "npm:^8.0.0"
qs: "npm:^6.9.4"
rc-progress: "npm:3.5.1"
@@ -3661,6 +3662,8 @@ __metadata:
react-use: "npm:^17.3.2"
react-virtualized-auto-sizer: "npm:^1.0.11"
react-window: "npm:^1.8.6"
rehype-raw: "npm:^6.0.0"
rehype-sanitize: "npm:^5.0.0"
remark-gfm: "npm:^3.0.1"
zen-observable: "npm:^0.10.0"
zod: "npm:^3.22.4"
@@ -21464,6 +21467,13 @@ __metadata:
languageName: node
linkType: hard
"@types/parse5@npm:^6.0.0":
version: 6.0.3
resolution: "@types/parse5@npm:6.0.3"
checksum: 10/834d40c9b1a8a99a9574b0b3f6629cf48adcff2eda01a35d701f1de5dcf46ce24223684647890aba9f985d6c801b233f878168683de0ae425940403c383fba8f
languageName: node
linkType: hard
"@types/passport-auth0@npm:^1.0.5":
version: 1.0.9
resolution: "@types/passport-auth0@npm:1.0.9"
@@ -33057,6 +33067,21 @@ __metadata:
languageName: node
linkType: hard
"hast-util-from-parse5@npm:^7.0.0":
version: 7.1.2
resolution: "hast-util-from-parse5@npm:7.1.2"
dependencies:
"@types/hast": "npm:^2.0.0"
"@types/unist": "npm:^2.0.0"
hastscript: "npm:^7.0.0"
property-information: "npm:^6.0.0"
vfile: "npm:^5.0.0"
vfile-location: "npm:^4.0.0"
web-namespaces: "npm:^2.0.0"
checksum: 10/7a90a16430a1482ed1be5c2f8b182e8b12aee8834781245b101700b5a04cea8b569cf40ef08214e1eb333249432e861b17e6fe46d0447b5281827c8798e86f1a
languageName: node
linkType: hard
"hast-util-parse-selector@npm:^2.0.0":
version: 2.2.4
resolution: "hast-util-parse-selector@npm:2.2.4"
@@ -33064,6 +33089,15 @@ __metadata:
languageName: node
linkType: hard
"hast-util-parse-selector@npm:^3.0.0":
version: 3.1.1
resolution: "hast-util-parse-selector@npm:3.1.1"
dependencies:
"@types/hast": "npm:^2.0.0"
checksum: 10/511d373465f60dd65e924f88bf0954085f4fb6e3a2b062a4b5ac43b93cbfd36a8dce6234b5d1e3e63499d936375687e83fc5da55628b22bd6b581b5ee167d1c4
languageName: node
linkType: hard
"hast-util-parse-selector@npm:^4.0.0":
version: 4.0.0
resolution: "hast-util-parse-selector@npm:4.0.0"
@@ -33073,6 +33107,48 @@ __metadata:
languageName: node
linkType: hard
"hast-util-raw@npm:^7.2.0":
version: 7.2.3
resolution: "hast-util-raw@npm:7.2.3"
dependencies:
"@types/hast": "npm:^2.0.0"
"@types/parse5": "npm:^6.0.0"
hast-util-from-parse5: "npm:^7.0.0"
hast-util-to-parse5: "npm:^7.0.0"
html-void-elements: "npm:^2.0.0"
parse5: "npm:^6.0.0"
unist-util-position: "npm:^4.0.0"
unist-util-visit: "npm:^4.0.0"
vfile: "npm:^5.0.0"
web-namespaces: "npm:^2.0.0"
zwitch: "npm:^2.0.0"
checksum: 10/fe39d4b9e68de7131ec61e9abe887cc0579dc7491f738735150c6021565fc998e37c9d096e97fc35c769e986e04b721d4724835ee82fcc22076d778acf6c4832
languageName: node
linkType: hard
"hast-util-sanitize@npm:^4.0.0":
version: 4.1.0
resolution: "hast-util-sanitize@npm:4.1.0"
dependencies:
"@types/hast": "npm:^2.0.0"
checksum: 10/8258ba32be9e57e871f894d3c6b3187dd98e0682bf1382cd1306c20045cd5dc209589919817dc079803d9a259b420d9cd88771535088c9fe0eea122028c348eb
languageName: node
linkType: hard
"hast-util-to-parse5@npm:^7.0.0":
version: 7.1.0
resolution: "hast-util-to-parse5@npm:7.1.0"
dependencies:
"@types/hast": "npm:^2.0.0"
comma-separated-tokens: "npm:^2.0.0"
property-information: "npm:^6.0.0"
space-separated-tokens: "npm:^2.0.0"
web-namespaces: "npm:^2.0.0"
zwitch: "npm:^2.0.0"
checksum: 10/695539881431f9713ca4a0be7d06bf3e57ae4d9f930eccba371534c50cff11855d345f8ec30099d04482637ad82e3c70d480269bfa4c109f37993536e8ea690d
languageName: node
linkType: hard
"hast-util-whitespace@npm:^2.0.0":
version: 2.0.0
resolution: "hast-util-whitespace@npm:2.0.0"
@@ -33093,6 +33169,19 @@ __metadata:
languageName: node
linkType: hard
"hastscript@npm:^7.0.0":
version: 7.2.0
resolution: "hastscript@npm:7.2.0"
dependencies:
"@types/hast": "npm:^2.0.0"
comma-separated-tokens: "npm:^2.0.0"
hast-util-parse-selector: "npm:^3.0.0"
property-information: "npm:^6.0.0"
space-separated-tokens: "npm:^2.0.0"
checksum: 10/98740e0b69b4765a23d0174fb93eb1c1bdcae6a9f1c9e1b07de6aca2d578427a42e1d45ee98eda26463ac58ff73a8ce45af19c4eb8b5f6f768a9c8543964d28f
languageName: node
linkType: hard
"hastscript@npm:^9.0.0":
version: 9.0.1
resolution: "hastscript@npm:9.0.1"
@@ -33300,6 +33389,13 @@ __metadata:
languageName: node
linkType: hard
"html-void-elements@npm:^2.0.0":
version: 2.0.1
resolution: "html-void-elements@npm:2.0.1"
checksum: 10/06d41f13b9d5d6e0f39861c4bec9a9196fa4906d56cd5cf6cf54ad2e52a85bf960cca2bf9600026bde16c8331db171bedba5e5a35e2e43630c8f1d497b2fb658
languageName: node
linkType: hard
"html-webpack-plugin@npm:^5.6.3":
version: 5.6.5
resolution: "html-webpack-plugin@npm:5.6.5"
@@ -41217,6 +41313,13 @@ __metadata:
languageName: node
linkType: hard
"parse5@npm:^6.0.0":
version: 6.0.1
resolution: "parse5@npm:6.0.1"
checksum: 10/dfb110581f62bd1425725a7c784ae022a24669bd0efc24b58c71fc731c4d868193e2ebd85b74cde2dbb965e4dcf07059b1e651adbec1b3b5267531bd132fdb75
languageName: node
linkType: hard
"parse5@npm:^7.0.0, parse5@npm:^7.1.2":
version: 7.3.0
resolution: "parse5@npm:7.3.0"
@@ -44471,6 +44574,28 @@ __metadata:
languageName: node
linkType: hard
"rehype-raw@npm:^6.0.0":
version: 6.1.1
resolution: "rehype-raw@npm:6.1.1"
dependencies:
"@types/hast": "npm:^2.0.0"
hast-util-raw: "npm:^7.2.0"
unified: "npm:^10.0.0"
checksum: 10/3599d22c45264bea52c93eec2136f50f119282c0bd4e9604aeb2421fe20db84f9c4536caebf64f29158d8c2403b6fd3b3da634211393fdda9cdd500149d00ae4
languageName: node
linkType: hard
"rehype-sanitize@npm:^5.0.0":
version: 5.0.1
resolution: "rehype-sanitize@npm:5.0.1"
dependencies:
"@types/hast": "npm:^2.0.0"
hast-util-sanitize: "npm:^4.0.0"
unified: "npm:^10.0.0"
checksum: 10/b9d9efc0b3c894fe5da84558147148c355c933794c3411dc7c02278b28ea251d241020cd26836e5cc5c979e0e058b45ca51a0eb87824ffdb7241efecd65b6d29
languageName: node
linkType: hard
"relateurl@npm:^0.2.7":
version: 0.2.7
resolution: "relateurl@npm:0.2.7"
@@ -49700,6 +49825,16 @@ __metadata:
languageName: node
linkType: hard
"vfile-location@npm:^4.0.0":
version: 4.1.0
resolution: "vfile-location@npm:4.1.0"
dependencies:
"@types/unist": "npm:^2.0.0"
vfile: "npm:^5.0.0"
checksum: 10/c894e8e5224170d1f85288f4a1d1ebcee0780823ea2b49d881648ab360ebf01b37ecb09b1c4439a75f9a51f31a9f9742cd045e987763e367c352a1ef7c50d446
languageName: node
linkType: hard
"vfile-message@npm:^3.0.0":
version: 3.0.2
resolution: "vfile-message@npm:3.0.2"
@@ -49924,6 +50059,13 @@ __metadata:
languageName: node
linkType: hard
"web-namespaces@npm:^2.0.0":
version: 2.0.1
resolution: "web-namespaces@npm:2.0.1"
checksum: 10/b6d9f02f1a43d0ef0848a812d89c83801d5bbad57d8bb61f02eb6d7eb794c3736f6cc2e1191664bb26136594c8218ac609f4069722c6f56d9fc2d808fa9271c6
languageName: node
linkType: hard
"web-streams-polyfill@npm:4.0.0-beta.3":
version: 4.0.0-beta.3
resolution: "web-streams-polyfill@npm:4.0.0-beta.3"