Skip to content

Commit

Permalink
Merge pull request #8644 from marmelab/fix-RichTextField-XSS
Browse files Browse the repository at this point in the history
Fix `<RichTextField>` XSS vulnerability
  • Loading branch information
slax57 committed Feb 13, 2023
2 parents 450635d + 9b3eed2 commit c1891af
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 5 deletions.
11 changes: 8 additions & 3 deletions docs/RichTextField.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,28 @@ title: "The RichTextField Component"

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

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

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.

## Usage

```jsx
import { RichTextField } from 'react-admin';

<RichTextField source="body" />
```

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

## Properties
## Props

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

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

## Usage
## `stripTags`

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).

Expand Down
1 change: 1 addition & 0 deletions packages/ra-ui-materialui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"autosuggest-highlight": "^3.1.1",
"clsx": "^1.1.1",
"css-mediaquery": "^0.1.2",
"dompurify": "^2.4.3",
"inflection": "~1.12.0",
"jsonexport": "^3.2.0",
"lodash": "~4.17.5",
Expand Down
15 changes: 14 additions & 1 deletion packages/ra-ui-materialui/src/field/RichTextField.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as React from 'react';
import expect from 'expect';
import { render } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { RecordContextProvider } from 'ra-core';

import { RichTextField, removeTags } from './RichTextField';
import { Secure } from './RichTextField.stories';

describe('stripTags', () => {
it('should strip HTML tags from input', () => {
Expand Down Expand Up @@ -135,4 +136,16 @@ describe('<RichTextField />', () => {
expect(queryByText('NA')).not.toBeNull();
}
);

it('should be safe by default', async () => {
const { container } = render(<Secure />);
fireEvent.mouseOver(
screen.getByText(
"It is regarded as one of Tolstoy's finest literary achievements and remains a classic of world literature."
)
);
expect(
(container.querySelector('#stolendata') as HTMLInputElement)?.value
).toEqual('none');
});
});
84 changes: 84 additions & 0 deletions packages/ra-ui-materialui/src/field/RichTextField.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import * as React from 'react';
import { RecordContextProvider, useTimeout } from 'ra-core';
import dompurify from 'dompurify';

import { RichTextField } from './RichTextField';
import { SimpleShowLayout } from '../detail/SimpleShowLayout';

export default {
title: 'ra-ui-materialui/fields/RichTextField',
};

const record = {
id: 1,
body: `
<p>
<strong>War and Peace</strong> is a novel by the Russian author <a href="https://en.wikipedia.org/wiki/Leo_Tolstoy">Leo Tolstoy</a>,
published serially, then in its entirety in 1869.
</p>
<p>
It is regarded as one of Tolstoy's finest literary achievements and remains a classic of world literature.
</p>
<img src="https://upload.wikimedia.org/wikipedia/commons/a/af/Tolstoy_-_War_and_Peace_-_first_edition%2C_1869.jpg" />
`,
};

export const Basic = () => (
<RecordContextProvider value={record}>
<RichTextField source="body" />
</RecordContextProvider>
);

export const StripTags = () => (
<RecordContextProvider value={record}>
<RichTextField source="body" stripTags />
</RecordContextProvider>
);

export const InSimpleShowLayout = () => (
<RecordContextProvider value={record}>
<SimpleShowLayout>
<RichTextField source="body" />
</SimpleShowLayout>
</RecordContextProvider>
);

const DomPurifyInspector = () => {
useTimeout(100); // force a redraw after the lazy loading of dompurify
const dompurifyRemoved = dompurify.removed
.map(
removal =>
`removed attribute ${
removal.attribute.name
} from tag <${removal.from.tagName.toLowerCase()}>`
)
.join(', ');
return <em>{dompurifyRemoved}</em>;
};

export const Secure = () => (
<RecordContextProvider
value={{
id: 1,
body: `
<p>
<strong>War and Peace</strong> is a novel by the Russian author
<a href="https://en.wikipedia.org/wiki/Leo_Tolstoy" onclick="document.getElementById('stolendata').value='credentials';">Leo Tolstoy</a>,
published serially, then in its entirety in 1869.
</p>
<p onmouseover="document.getElementById('stolendata').value='credentials';">
It is regarded as one of Tolstoy's finest literary achievements and remains a classic of world literature.
</p>
<img src="x" onerror="document.getElementById('stolendata').value='credentials';" />
`,
}}
>
<RichTextField source="body" />
<hr />
<DomPurifyInspector />
<div>
<h4>Stolen data:</h4>
<input id="stolendata" defaultValue="none" />
</div>
</RecordContextProvider>
);
22 changes: 21 additions & 1 deletion packages/ra-ui-materialui/src/field/RichTextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,26 @@ import PropTypes from 'prop-types';
import get from 'lodash/get';
import Typography, { TypographyProps } from '@mui/material/Typography';
import { useRecordContext } from 'ra-core';
import purify from 'dompurify';

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

/**
* Render an HTML string as rich text
*
* Note: This component leverages the `dangerouslySetInnerHTML` attribute,
* but uses the DomPurify library 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.
*
* @example
* <RichTextField source="description" />
*
* @example // remove all tags and output text only
* <RichTextField source="description" stripTags />
*/
export const RichTextField: FC<RichTextFieldProps> = memo<RichTextFieldProps>(
props => {
const {
Expand All @@ -32,7 +48,11 @@ export const RichTextField: FC<RichTextFieldProps> = memo<RichTextFieldProps>(
) : stripTags ? (
removeTags(value)
) : (
<span dangerouslySetInnerHTML={{ __html: value }} />
<span
dangerouslySetInnerHTML={{
__html: purify.sanitize(value),
}}
/>
)}
</Typography>
);
Expand Down
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11655,6 +11655,13 @@ __metadata:
languageName: node
linkType: hard

"dompurify@npm:^2.4.3":
version: 2.4.3
resolution: "dompurify@npm:2.4.3"
checksum: 4c93f5bc8855bbe7dcb33487c0b252a00309fbd8a6d0ec280abbc3af695b43d1bf7f526c2f323fa697314b0b3de3511c756005dddc6ed90d1a1440a3d6ff89d9
languageName: node
linkType: hard

"domutils@npm:^1.7.0":
version: 1.7.0
resolution: "domutils@npm:1.7.0"
Expand Down Expand Up @@ -21922,6 +21929,7 @@ __metadata:
clsx: ^1.1.1
cross-env: ^5.2.0
css-mediaquery: ^0.1.2
dompurify: ^2.4.3
expect: ^27.4.6
file-api: ~0.10.4
history: ^5.1.0
Expand Down

0 comments on commit c1891af

Please sign in to comment.