diff --git a/docs/RichTextField.md b/docs/RichTextField.md
index c7d92caba39..9ed9adf95ef 100644
--- a/docs/RichTextField.md
+++ b/docs/RichTextField.md
@@ -7,15 +7,20 @@ 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](./img/rich-text-field.png)
-## Properties
+## Props
| Prop | Required | Type | Default | Description |
| ----------- | -------- | --------- | -------- | ---------------------------------------------------- |
@@ -23,7 +28,7 @@ import { RichTextField } from 'react-admin';
`` 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).
diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json
index 9383a0e8e88..001f566a9ff 100644
--- a/packages/ra-ui-materialui/package.json
+++ b/packages/ra-ui-materialui/package.json
@@ -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",
diff --git a/packages/ra-ui-materialui/src/field/RichTextField.spec.tsx b/packages/ra-ui-materialui/src/field/RichTextField.spec.tsx
index a24ac9a7bd2..f55eb56f0c9 100644
--- a/packages/ra-ui-materialui/src/field/RichTextField.spec.tsx
+++ b/packages/ra-ui-materialui/src/field/RichTextField.spec.tsx
@@ -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', () => {
@@ -135,4 +136,16 @@ describe('', () => {
expect(queryByText('NA')).not.toBeNull();
}
);
+
+ it('should be safe by default', async () => {
+ const { container } = render();
+ 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');
+ });
});
diff --git a/packages/ra-ui-materialui/src/field/RichTextField.stories.tsx b/packages/ra-ui-materialui/src/field/RichTextField.stories.tsx
new file mode 100644
index 00000000000..bbcd1f6910a
--- /dev/null
+++ b/packages/ra-ui-materialui/src/field/RichTextField.stories.tsx
@@ -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: `
+
+War and Peace is a novel by the Russian author Leo Tolstoy,
+published serially, then in its entirety in 1869.
+
+
+It is regarded as one of Tolstoy's finest literary achievements and remains a classic of world literature.
+
+
+`,
+};
+
+export const Basic = () => (
+
+
+
+);
+
+export const StripTags = () => (
+
+
+
+);
+
+export const InSimpleShowLayout = () => (
+
+
+
+
+
+);
+
+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 {dompurifyRemoved};
+};
+
+export const Secure = () => (
+
+War and Peace is a novel by the Russian author
+Leo Tolstoy,
+published serially, then in its entirety in 1869.
+
+
+It is regarded as one of Tolstoy's finest literary achievements and remains a classic of world literature.
+
+
+`,
+ }}
+ >
+
+
+
+
+
Stolen data:
+
+
+
+);
diff --git a/packages/ra-ui-materialui/src/field/RichTextField.tsx b/packages/ra-ui-materialui/src/field/RichTextField.tsx
index cf5d8161155..6ddaf24bb75 100644
--- a/packages/ra-ui-materialui/src/field/RichTextField.tsx
+++ b/packages/ra-ui-materialui/src/field/RichTextField.tsx
@@ -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
+ *
+ *
+ * @example // remove all tags and output text only
+ *
+ */
export const RichTextField: FC = memo(
props => {
const {
@@ -32,7 +48,11 @@ export const RichTextField: FC = memo(
) : stripTags ? (
removeTags(value)
) : (
-
+
)}
);
diff --git a/yarn.lock b/yarn.lock
index 17808989c68..aa3de01fbe5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"
@@ -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