Skip to content

Commit 3e82fe6

Browse files
authored
feat(web): HTML sanitization (#680)
# Summary - implemented sanitization on all ends of the `EnrichedTextInput` and `EnrichedText` - we were allowing all custom mention attributes names, inluding potentially malicious ones. Now the attributes are sanitized and if one doesn't start with the `data-` prefix, we print a runtime warning that it might get stripped by a sanitizer - the `'default'` mention style functionality wasn't documented - the normalizer let through only a predefined set of `mention` attributes: `id`, `text` and `indicator` - now it allows every custom attribute - implemented a new web-only prop `sanitizationConfig`, currently with only `linkRegex` field, allowing to persist custom links which would be otherwise stripped by the sanitizer ## Test Plan Try to create a mention with an illegal HTML attribute, eg. `onClick`. It should be stripped and a suitable console warning should be visible. ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ❌ | | Web | ✅ | ## Checklist - [x] E2E tests are passing - [x] Required E2E tests have been added (if applicable)
1 parent 4f284bb commit 3e82fe6

20 files changed

Lines changed: 430 additions & 40 deletions

.playwright/tests/links.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ test.describe('test-links copy-paste', () => {
479479

480480
await setTestLinksEditorHtml(
481481
page,
482-
'<html><p><a href="custom://link">custom://link</a></p></html>'
482+
'<html><p><a href="/custom-link">/custom-link</a></p></html>'
483483
);
484484

485485
await copyWholeContent(editor);
@@ -488,7 +488,7 @@ test.describe('test-links copy-paste', () => {
488488

489489
await expect
490490
.poll(async () => getTestLinksSerializedHtml(page))
491-
.toContain('<a href="custom://link">custom://link</a>');
491+
.toContain('<a href="/custom-link">/custom-link</a>');
492492
});
493493
});
494494

apps/example-web/src/App.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ const DEFAULT_LINK_STATE: OnLinkDetected = {
3838
const LINK_REGEX =
3939
/^(?:enriched:\/\/\S+|(?:https?:\/\/)?(?:www\.)?swmansion\.com(?:\/\S*)?)$/i;
4040

41+
const SANITIZATION_CONFIG = {
42+
linkRegex: LINK_REGEX,
43+
};
44+
4145
function App() {
4246
const ref = useRef<EnrichedTextInputInstance>(null);
4347
const [currentHtml, setCurrentHtml] = useState('');
@@ -121,16 +125,16 @@ function App() {
121125

122126
const handleUserMentionSelected = (item: MentionItem) => {
123127
ref.current?.setMention('@', `@${item.name}`, {
124-
id: item.id,
125-
type: 'user',
128+
'id': item.id,
129+
'data-type': 'user',
126130
});
127131
closeUserMentionPopup();
128132
};
129133

130134
const handleChannelMentionSelected = (item: MentionItem) => {
131135
ref.current?.setMention('#', `#${item.name}`, {
132-
id: item.id,
133-
type: 'channel',
136+
'id': item.id,
137+
'data-type': 'channel',
134138
});
135139
closeChannelMentionPopup();
136140
};
@@ -278,6 +282,7 @@ function App() {
278282
mentionIndicators={['@', '#']}
279283
htmlStyle={WEB_DEFAULT_HTML_STYLE}
280284
linkRegex={LINK_REGEX}
285+
sanitizationConfig={SANITIZATION_CONFIG}
281286
/>
282287
<MentionPopup
283288
variant="user"

apps/example-web/src/components/TextRenderer.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import {
1010
} from 'react-native-enriched-html';
1111
import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle';
1212

13+
const LINK_REGEX =
14+
/^(?:enriched:\/\/\S+|(?:https?:\/\/)?(?:www\.)?swmansion\.com(?:\/\S*)?)$/i;
15+
16+
const SANITIZATION_CONFIG = {
17+
linkRegex: LINK_REGEX,
18+
};
19+
1320
interface TextRendererProps {
1421
htmlValue: string;
1522
}
@@ -44,6 +51,7 @@ export function TextRenderer({ htmlValue }: TextRendererProps) {
4451
onBlur={handleTextBlur}
4552
onLinkPress={handleLinkPress}
4653
onMentionPress={handleMentionPress}
54+
sanitizationConfig={SANITIZATION_CONFIG}
4755
>
4856
{htmlValue}
4957
</EnrichedText>

cpp/parser/GumboNormalizer.c

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -513,9 +513,10 @@ static void emit_attributes(GumboElement *el, const char *tag_name,
513513
buffer_append_str(out, " checked");
514514
}
515515
} else if (strcmp(tag_name, "mention") == 0) {
516-
emit_one_attr(out, el, "id");
517-
emit_one_attr(out, el, "text");
518-
emit_one_attr(out, el, "indicator");
516+
for (unsigned int i = 0; i < el->attributes.length; i++) {
517+
GumboAttribute *attr = (GumboAttribute *)el->attributes.data[i];
518+
emit_one_attr(out, el, attr->name);
519+
}
519520
} else {
520521
/* preserve text-align */
521522
emit_alignment(el, tag_name, out);

cpp/tests/GumboParserTest.cpp

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,13 +307,20 @@ TEST(GumboParserTest, EnrichedTagRemappings) {
307307
EXPECT_EQ(
308308
GumboParser::normalizeHtml(
309309
"<mention text='@John Doe' indicator='@' id='1'>@John Doe</mention>"),
310-
"<mention id=\"1\" text=\"@John Doe\" indicator=\"@\">@John "
310+
"<mention text=\"@John Doe\" indicator=\"@\" id=\"1\">@John "
311311
"Doe</mention>");
312312
EXPECT_EQ(
313313
GumboParser::normalizeHtml("<mention text=\"@John Doe\" indicator=\"@\" "
314314
"id=\"1\">@John Doe</mention>"),
315-
"<mention id=\"1\" text=\"@John Doe\" indicator=\"@\">@John "
315+
"<mention text=\"@John Doe\" indicator=\"@\" id=\"1\">@John "
316316
"Doe</mention>");
317+
// Custom mention attributes are preserved
318+
EXPECT_EQ(
319+
GumboParser::normalizeHtml(
320+
"<mention id=\"1\" text=\"@John Doe\" indicator=\"@\" type=\"user\" "
321+
"data-custom=\"custom data\">@John Doe</mention>"),
322+
"<mention id=\"1\" text=\"@John Doe\" indicator=\"@\" type=\"user\" "
323+
"data-custom=\"custom data\">@John Doe</mention>");
317324

318325
// Link
319326
EXPECT_EQ(GumboParser::normalizeHtml(

docs/INPUT_API_REFERENCE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1041,7 +1041,7 @@ interface MentionStyleProperties {
10411041

10421042
### mention
10431043

1044-
If only a single config is given, the style applies to all mention types. You can also set a different config for each mentionIndicator that has been defined, then the prop should be a record with indicators as a keys and configs as their values.
1044+
If only a single config is given, the style applies to all mention types. You can also set a different config for each mentionIndicator that has been defined, then the prop should be a record with indicators as keys and configs as their values. Additionally, you can define a style using the `'default'` key, which will act as a base that the rest of your defined styles will fallback on.
10451045

10461046
- `color` defines the color of mention's text, takes [color](https://reactnative.dev/docs/colors) value and defaults to `blue`.
10471047
- `backgroundColor` is the mention's background color, takes [color](https://reactnative.dev/docs/colors) value and defaults to `yellow`.

docs/WEB.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,39 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo
5151

5252
## HTML sanitization
5353

54-
You are responsible for sanitizing HTML on both input and output. The library does not guarantee safe or clean HTML output. This applies to any HTML you persist, render elsewhere, or accept from untrusted sources (XSS, paste attacks, etc.).
54+
On web, HTML is sanitized automatically with [DOMPurify](https://github.com/cure53/DOMPurify) on both input and output. This reduces XSS risk, but you should still treat untrusted HTML with caution and apply your own server-side sanitization.
55+
56+
- **`EnrichedText`** sanitizes its `children` before rendering.
57+
- **`EnrichedTextInput`** sanitizes every HTML entry point — `defaultValue`, the `setValue` ref method, and pasted HTML — as well as its output from `getHTML` and the `onChangeHtml` callback.
58+
59+
### Allowing custom link protocols
60+
61+
By default, sanitization strips links with non-standard protocols (e.g. `custom://…`). Both `EnrichedText` and `EnrichedTextInput` accept a web-only `sanitizationConfig` prop whose `linkRegex` field lets you control which link URIs survive.
62+
63+
`linkRegex` maps directly to DOMPurify's [`ALLOWED_URI_REGEXP`](https://github.com/cure53/DOMPurify#can-i-configure-dompurify), so it **replaces** the default allow-list rather than extending it — remember to keep the standard protocols you still want to permit:
64+
65+
```tsx
66+
<EnrichedText
67+
sanitizationConfig={{
68+
// Permit the usual protocols plus a custom "custom://" scheme.
69+
linkRegex:
70+
/^(?:(?:(?:f|ht)tps?|mailto|tel|custom):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i,
71+
}}
72+
>
73+
{html}
74+
</EnrichedText>
75+
```
76+
77+
When `sanitizationConfig` is omitted, DOMPurify's built-in default is used.
78+
79+
> Note: `sanitizationConfig.linkRegex` only controls what sanitization keeps. It is independent of the top-level `linkRegex` prop, which controls autolink detection while typing. To both autolink and preserve a custom protocol, configure both.
80+
81+
### Custom mention attributes
82+
83+
To attach custom data to a mention, use the `data-` prefix (e.g. `data-user-id`) to make sure they survive sanitization. Attributes passed to the `setMention` ref method are properly sanitized.
84+
85+
## Client-only rendering (no SSR)
86+
87+
Both `EnrichedText` and `EnrichedTextInput` are **client-only** components. They rely on browser-only APIs (`DOMParser`, `DOMPurify`, `TipTap`) and are **not designed for server-side rendering (SSR)**.
88+
89+
If your application uses SSR (Next.js, Remix, Gatsby, etc.), make sure these components only render on the client.

src/types.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,25 @@ export interface OnChangeMentionEvent {
585585
text: string;
586586
}
587587

588+
/**
589+
* Web-only configuration for the HTML sanitization step.
590+
*
591+
* @platform web
592+
*/
593+
export interface SanitizationConfig {
594+
/**
595+
* Regular expression used to decide which link URIs survive sanitization.
596+
* Maps directly to DOMPurify's `ALLOWED_URI_REGEXP`, so it fully replaces
597+
* the default allow-list rather than extending it — include the standard
598+
* protocols you still want to permit in addition to any custom ones.
599+
*
600+
* When omitted, DOMPurify's built-in default is used.
601+
*
602+
* @platform web
603+
*/
604+
linkRegex?: RegExp;
605+
}
606+
588607
/**
589608
* Props for the `<EnrichedTextInput />` rich-text editor component.
590609
*/
@@ -762,6 +781,13 @@ export interface EnrichedTextInputProps extends Omit<ViewProps, 'children'> {
762781
*/
763782
useHtmlNormalizer?: boolean;
764783

784+
/**
785+
* Web-only configuration for the HTML sanitization step.
786+
*
787+
* @platform web
788+
*/
789+
sanitizationConfig?: SanitizationConfig;
790+
765791
/**
766792
* If true, fonts will scale to respect the system's accessibility text size.
767793
* Enabled by default.
@@ -804,6 +830,13 @@ export interface EnrichedTextProps extends ViewProps {
804830
*/
805831
useHtmlNormalizer?: boolean;
806832

833+
/**
834+
* Web-only configuration for the HTML sanitization step.
835+
*
836+
* @platform web
837+
*/
838+
sanitizationConfig?: SanitizationConfig;
839+
807840
/**
808841
* How to truncate text when it overflows `numberOfLines`.
809842
* - `"head"` — truncates the beginning.

src/web/EnrichedText.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useImageErrorFallback } from './useImageErrorFallback';
2020
import { usePressInteractions } from './usePressInteractions';
2121
import { adaptWebToNativeEvent } from './adaptWebToNativeEvent';
2222
import { useStableRef } from './useStableRef';
23+
import { assertBrowserEnvironment } from './assertBrowserEnvironment';
2324

2425
export const EnrichedText = memo(
2526
({
@@ -30,11 +31,14 @@ export const EnrichedText = memo(
3031
selectionColor,
3132
selectable = false,
3233
useHtmlNormalizer = true,
34+
sanitizationConfig,
3335
onFocus,
3436
onBlur,
3537
onLinkPress,
3638
onMentionPress,
3739
}: EnrichedTextProps) => {
40+
assertBrowserEnvironment('EnrichedText');
41+
3842
const containerRef = useRef<HTMLDivElement>(null);
3943

4044
useImperativeHandle(ref, () => ({
@@ -50,7 +54,10 @@ export const EnrichedText = memo(
5054
},
5155
}));
5256

53-
const sanitizedHtml = useMemo(() => sanitizeHtml(children), [children]);
57+
const sanitizedHtml = useMemo(
58+
() => sanitizeHtml(children, sanitizationConfig),
59+
[children, sanitizationConfig]
60+
);
5461

5562
const finalHtml = useMemo(
5663
() => prepareHtmlForWeb(sanitizedHtml, useHtmlNormalizer),

src/web/EnrichedTextInput.tsx

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ import { returnKeyTypeToEnterKeyHint } from './returnKeyTypeToEnterKeyHint';
8181
import { ENRICHED_TEXT_INPUT_CLASSNAME } from './constants/classNames';
8282
import { AutolinkPlugin } from './pmPlugins/AutolinkPlugin';
8383
import { useStableRef } from './useStableRef';
84+
import {
85+
checkMentionAttributes,
86+
sanitizeMentionAttributes,
87+
} from './sanitization/htmlSanitizer';
88+
import { assertBrowserEnvironment } from './assertBrowserEnvironment';
8489

8590
function runFocused(
8691
editor: Editor,
@@ -121,11 +126,18 @@ export const EnrichedTextInput = ({
121126
linkRegex,
122127
htmlStyle,
123128
useHtmlNormalizer = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.useHtmlNormalizer,
129+
sanitizationConfig,
124130
textShortcuts = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.textShortcuts,
125131
}: EnrichedTextInputProps) => {
132+
assertBrowserEnvironment('EnrichedTextInput');
133+
126134
const tiptapContent =
127135
defaultValue != null
128-
? prepareHtmlForTiptap(defaultValue, useHtmlNormalizer)
136+
? prepareHtmlForTiptap(
137+
defaultValue,
138+
useHtmlNormalizer,
139+
sanitizationConfig
140+
)
129141
: defaultValue;
130142

131143
const resolvedHtmlStyle = useMemo(
@@ -149,6 +161,7 @@ export const EnrichedTextInput = ({
149161
const onSubmitEditingRef = useStableRef(onSubmitEditing);
150162
const onKeyPressRef = useStableRef(onKeyPress);
151163
const useHtmlNormalizerRef = useStableRef(useHtmlNormalizer);
164+
const sanitizationConfigRef = useStableRef(sanitizationConfig);
152165
const mentionCallbacksRef = useStableRef(mentionCallbacks);
153166
const textShortcutsRef = useStableRef(textShortcuts);
154167

@@ -274,7 +287,11 @@ export const EnrichedTextInput = ({
274287
enterkeyhint: returnKeyTypeToEnterKeyHint(returnKeyType),
275288
},
276289
transformPastedHTML: (html) => {
277-
return prepareHtmlForTiptap(html, useHtmlNormalizerRef.current);
290+
return prepareHtmlForTiptap(
291+
html,
292+
useHtmlNormalizerRef.current,
293+
sanitizationConfigRef.current
294+
);
278295
},
279296
},
280297
},
@@ -309,7 +326,7 @@ export const EnrichedTextInput = ({
309326
);
310327

311328
useMentionEvents(editor, getMentionCallbacks);
312-
useOnChangeHtml(editor, onChangeHtml);
329+
useOnChangeHtml(editor, onChangeHtml, sanitizationConfig);
313330
useOnChangeText(editor, onChangeText);
314331
useOnChangeState(editor, resolvedHtmlStyle, onChangeState);
315332
useOnLinkDetected(editor, linkEmitterRef);
@@ -321,7 +338,11 @@ export const EnrichedTextInput = ({
321338
blur: () => editor.commands.blur(),
322339
setValue: (value: string) =>
323340
editor.commands.setContent(
324-
prepareHtmlForTiptap(value, useHtmlNormalizerRef.current)
341+
prepareHtmlForTiptap(
342+
value,
343+
useHtmlNormalizerRef.current,
344+
sanitizationConfigRef.current
345+
)
325346
),
326347
setSelection: (start, end) => {
327348
const doc = editor.state.doc;
@@ -332,7 +353,13 @@ export const EnrichedTextInput = ({
332353
})
333354
);
334355
},
335-
getHTML: () => Promise.resolve(normalizeHtmlFromTiptap(editor.getHTML())),
356+
getHTML: () =>
357+
Promise.resolve(
358+
normalizeHtmlFromTiptap(
359+
editor.getHTML(),
360+
sanitizationConfigRef.current
361+
)
362+
),
336363
toggleBold: () => runFocused(editor, (c) => c.toggleBold()),
337364
toggleItalic: () => runFocused(editor, (c) => c.toggleItalic()),
338365
toggleUnderline: () => runFocused(editor, (c) => c.toggleUnderline()),
@@ -362,7 +389,15 @@ export const EnrichedTextInput = ({
362389
indicator: string,
363390
text: string,
364391
attributes?: Record<string, string>
365-
) => setMention(editor, indicator, text, attributes),
392+
) => {
393+
checkMentionAttributes(attributes);
394+
setMention(
395+
editor,
396+
indicator,
397+
text,
398+
sanitizeMentionAttributes(attributes)
399+
);
400+
},
366401
setImage: (src: string, width: number, height: number) =>
367402
runFocused(editor, (c) => c.setImage({ src, width, height })),
368403
measure: () => {},
@@ -377,7 +412,7 @@ export const EnrichedTextInput = ({
377412
}
378413
},
379414
}),
380-
[editor, mentionIndicatorsRef, useHtmlNormalizerRef]
415+
[editor, mentionIndicatorsRef, useHtmlNormalizerRef, sanitizationConfigRef]
381416
);
382417

383418
const editorStyle: CSSProperties = useMemo(

0 commit comments

Comments
 (0)