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