Skip to content

Commit c1891af

Browse files
authored
Merge pull request #8644 from marmelab/fix-RichTextField-XSS
Fix `<RichTextField>` XSS vulnerability
2 parents 450635d + 9b3eed2 commit c1891af

File tree

6 files changed

+136
-5
lines changed

6 files changed

+136
-5
lines changed

docs/RichTextField.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,28 @@ title: "The RichTextField Component"
77

88
This component displays some HTML content. The content is "rich" (i.e. unescaped) by default.
99

10+
![RichTextField](./img/rich-text-field.png)
11+
12+
This component leverages [the `dangerouslySetInnerHTML` attribute](https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml), but uses [the DomPurify library](https://github.com/cure53/DOMPurify) to sanitize the HTML before rendering it. It means it is **safe from Cross-Site Scripting (XSS) attacks** - but it's still a good practice to sanitize the value server-side.
13+
14+
## Usage
15+
1016
```jsx
1117
import { RichTextField } from 'react-admin';
1218

1319
<RichTextField source="body" />
1420
```
1521

16-
![RichTextField](./img/rich-text-field.png)
1722

18-
## Properties
23+
## Props
1924

2025
| Prop | Required | Type | Default | Description |
2126
| ----------- | -------- | --------- | -------- | ---------------------------------------------------- |
2227
| `stripTags` | Optional | `boolean` | `false` | If `true`, remove all HTML tags and render text only |
2328

2429
`<RichTextField>` also accepts the [common field props](./Fields.md#common-field-props).
2530

26-
## Usage
31+
## `stripTags`
2732

2833
The `stripTags` prop allows to remove all HTML markup, preventing some display glitches (which is especially useful in list views, or when truncating the content).
2934

packages/ra-ui-materialui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"autosuggest-highlight": "^3.1.1",
6161
"clsx": "^1.1.1",
6262
"css-mediaquery": "^0.1.2",
63+
"dompurify": "^2.4.3",
6364
"inflection": "~1.12.0",
6465
"jsonexport": "^3.2.0",
6566
"lodash": "~4.17.5",

packages/ra-ui-materialui/src/field/RichTextField.spec.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import * as React from 'react';
22
import expect from 'expect';
3-
import { render } from '@testing-library/react';
3+
import { render, screen, fireEvent } from '@testing-library/react';
44
import { RecordContextProvider } from 'ra-core';
55

66
import { RichTextField, removeTags } from './RichTextField';
7+
import { Secure } from './RichTextField.stories';
78

89
describe('stripTags', () => {
910
it('should strip HTML tags from input', () => {
@@ -135,4 +136,16 @@ describe('<RichTextField />', () => {
135136
expect(queryByText('NA')).not.toBeNull();
136137
}
137138
);
139+
140+
it('should be safe by default', async () => {
141+
const { container } = render(<Secure />);
142+
fireEvent.mouseOver(
143+
screen.getByText(
144+
"It is regarded as one of Tolstoy's finest literary achievements and remains a classic of world literature."
145+
)
146+
);
147+
expect(
148+
(container.querySelector('#stolendata') as HTMLInputElement)?.value
149+
).toEqual('none');
150+
});
138151
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import * as React from 'react';
2+
import { RecordContextProvider, useTimeout } from 'ra-core';
3+
import dompurify from 'dompurify';
4+
5+
import { RichTextField } from './RichTextField';
6+
import { SimpleShowLayout } from '../detail/SimpleShowLayout';
7+
8+
export default {
9+
title: 'ra-ui-materialui/fields/RichTextField',
10+
};
11+
12+
const record = {
13+
id: 1,
14+
body: `
15+
<p>
16+
<strong>War and Peace</strong> is a novel by the Russian author <a href="https://en.wikipedia.org/wiki/Leo_Tolstoy">Leo Tolstoy</a>,
17+
published serially, then in its entirety in 1869.
18+
</p>
19+
<p>
20+
It is regarded as one of Tolstoy's finest literary achievements and remains a classic of world literature.
21+
</p>
22+
<img src="https://upload.wikimedia.org/wikipedia/commons/a/af/Tolstoy_-_War_and_Peace_-_first_edition%2C_1869.jpg" />
23+
`,
24+
};
25+
26+
export const Basic = () => (
27+
<RecordContextProvider value={record}>
28+
<RichTextField source="body" />
29+
</RecordContextProvider>
30+
);
31+
32+
export const StripTags = () => (
33+
<RecordContextProvider value={record}>
34+
<RichTextField source="body" stripTags />
35+
</RecordContextProvider>
36+
);
37+
38+
export const InSimpleShowLayout = () => (
39+
<RecordContextProvider value={record}>
40+
<SimpleShowLayout>
41+
<RichTextField source="body" />
42+
</SimpleShowLayout>
43+
</RecordContextProvider>
44+
);
45+
46+
const DomPurifyInspector = () => {
47+
useTimeout(100); // force a redraw after the lazy loading of dompurify
48+
const dompurifyRemoved = dompurify.removed
49+
.map(
50+
removal =>
51+
`removed attribute ${
52+
removal.attribute.name
53+
} from tag <${removal.from.tagName.toLowerCase()}>`
54+
)
55+
.join(', ');
56+
return <em>{dompurifyRemoved}</em>;
57+
};
58+
59+
export const Secure = () => (
60+
<RecordContextProvider
61+
value={{
62+
id: 1,
63+
body: `
64+
<p>
65+
<strong>War and Peace</strong> is a novel by the Russian author
66+
<a href="https://en.wikipedia.org/wiki/Leo_Tolstoy" onclick="document.getElementById('stolendata').value='credentials';">Leo Tolstoy</a>,
67+
published serially, then in its entirety in 1869.
68+
</p>
69+
<p onmouseover="document.getElementById('stolendata').value='credentials';">
70+
It is regarded as one of Tolstoy's finest literary achievements and remains a classic of world literature.
71+
</p>
72+
<img src="x" onerror="document.getElementById('stolendata').value='credentials';" />
73+
`,
74+
}}
75+
>
76+
<RichTextField source="body" />
77+
<hr />
78+
<DomPurifyInspector />
79+
<div>
80+
<h4>Stolen data:</h4>
81+
<input id="stolendata" defaultValue="none" />
82+
</div>
83+
</RecordContextProvider>
84+
);

packages/ra-ui-materialui/src/field/RichTextField.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,26 @@ import PropTypes from 'prop-types';
44
import get from 'lodash/get';
55
import Typography, { TypographyProps } from '@mui/material/Typography';
66
import { useRecordContext } from 'ra-core';
7+
import purify from 'dompurify';
78

89
import { sanitizeFieldRestProps } from './sanitizeFieldRestProps';
910
import { InjectedFieldProps, PublicFieldProps, fieldPropTypes } from './types';
1011

12+
/**
13+
* Render an HTML string as rich text
14+
*
15+
* Note: This component leverages the `dangerouslySetInnerHTML` attribute,
16+
* but uses the DomPurify library to sanitize the HTML before rendering it.
17+
*
18+
* It means it is safe from Cross-Site Scripting (XSS) attacks - but it's still
19+
* a good practice to sanitize the value server-side.
20+
*
21+
* @example
22+
* <RichTextField source="description" />
23+
*
24+
* @example // remove all tags and output text only
25+
* <RichTextField source="description" stripTags />
26+
*/
1127
export const RichTextField: FC<RichTextFieldProps> = memo<RichTextFieldProps>(
1228
props => {
1329
const {
@@ -32,7 +48,11 @@ export const RichTextField: FC<RichTextFieldProps> = memo<RichTextFieldProps>(
3248
) : stripTags ? (
3349
removeTags(value)
3450
) : (
35-
<span dangerouslySetInnerHTML={{ __html: value }} />
51+
<span
52+
dangerouslySetInnerHTML={{
53+
__html: purify.sanitize(value),
54+
}}
55+
/>
3656
)}
3757
</Typography>
3858
);

yarn.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11655,6 +11655,13 @@ __metadata:
1165511655
languageName: node
1165611656
linkType: hard
1165711657

11658+
"dompurify@npm:^2.4.3":
11659+
version: 2.4.3
11660+
resolution: "dompurify@npm:2.4.3"
11661+
checksum: 4c93f5bc8855bbe7dcb33487c0b252a00309fbd8a6d0ec280abbc3af695b43d1bf7f526c2f323fa697314b0b3de3511c756005dddc6ed90d1a1440a3d6ff89d9
11662+
languageName: node
11663+
linkType: hard
11664+
1165811665
"domutils@npm:^1.7.0":
1165911666
version: 1.7.0
1166011667
resolution: "domutils@npm:1.7.0"
@@ -21922,6 +21929,7 @@ __metadata:
2192221929
clsx: ^1.1.1
2192321930
cross-env: ^5.2.0
2192421931
css-mediaquery: ^0.1.2
21932+
dompurify: ^2.4.3
2192521933
expect: ^27.4.6
2192621934
file-api: ~0.10.4
2192721935
history: ^5.1.0

0 commit comments

Comments
 (0)