Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .playwright/tests/links.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ test.describe('test-links copy-paste', () => {

await setTestLinksEditorHtml(
page,
'<html><p><a href="custom://link">custom://link</a></p></html>'
'<html><p><a href="/custom-link">/custom-link</a></p></html>'
);

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

await expect
.poll(async () => getTestLinksSerializedHtml(page))
.toContain('<a href="custom://link">custom://link</a>');
.toContain('<a href="/custom-link">/custom-link</a>');
});
});

Expand Down
13 changes: 9 additions & 4 deletions apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const DEFAULT_LINK_STATE: OnLinkDetected = {
const LINK_REGEX =
/^(?:enriched:\/\/\S+|(?:https?:\/\/)?(?:www\.)?swmansion\.com(?:\/\S*)?)$/i;

const SANITIZATION_CONFIG = {
linkRegex: LINK_REGEX,
};

function App() {
const ref = useRef<EnrichedTextInputInstance>(null);
const [currentHtml, setCurrentHtml] = useState('');
Expand Down Expand Up @@ -121,16 +125,16 @@ function App() {

const handleUserMentionSelected = (item: MentionItem) => {
ref.current?.setMention('@', `@${item.name}`, {
id: item.id,
type: 'user',
'id': item.id,
'data-type': 'user',
});
closeUserMentionPopup();
};

const handleChannelMentionSelected = (item: MentionItem) => {
ref.current?.setMention('#', `#${item.name}`, {
id: item.id,
type: 'channel',
'id': item.id,
'data-type': 'channel',
});
closeChannelMentionPopup();
};
Expand Down Expand Up @@ -278,6 +282,7 @@ function App() {
mentionIndicators={['@', '#']}
htmlStyle={WEB_DEFAULT_HTML_STYLE}
linkRegex={LINK_REGEX}
sanitizationConfig={SANITIZATION_CONFIG}
/>
<MentionPopup
variant="user"
Expand Down
8 changes: 8 additions & 0 deletions apps/example-web/src/components/TextRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import {
} from 'react-native-enriched-html';
import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle';

const LINK_REGEX =
/^(?:enriched:\/\/\S+|(?:https?:\/\/)?(?:www\.)?swmansion\.com(?:\/\S*)?)$/i;

const SANITIZATION_CONFIG = {
linkRegex: LINK_REGEX,
};

interface TextRendererProps {
htmlValue: string;
}
Expand Down Expand Up @@ -44,6 +51,7 @@ export function TextRenderer({ htmlValue }: TextRendererProps) {
onBlur={handleTextBlur}
onLinkPress={handleLinkPress}
onMentionPress={handleMentionPress}
sanitizationConfig={SANITIZATION_CONFIG}
>
{htmlValue}
</EnrichedText>
Expand Down
7 changes: 4 additions & 3 deletions cpp/parser/GumboNormalizer.c
Original file line number Diff line number Diff line change
Expand Up @@ -513,9 +513,10 @@ static void emit_attributes(GumboElement *el, const char *tag_name,
buffer_append_str(out, " checked");
}
} else if (strcmp(tag_name, "mention") == 0) {
emit_one_attr(out, el, "id");
emit_one_attr(out, el, "text");
emit_one_attr(out, el, "indicator");
for (unsigned int i = 0; i < el->attributes.length; i++) {
GumboAttribute *attr = (GumboAttribute *)el->attributes.data[i];
emit_one_attr(out, el, attr->name);
}
} else {
/* preserve text-align */
emit_alignment(el, tag_name, out);
Expand Down
11 changes: 9 additions & 2 deletions cpp/tests/GumboParserTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -307,13 +307,20 @@ TEST(GumboParserTest, EnrichedTagRemappings) {
EXPECT_EQ(
GumboParser::normalizeHtml(
"<mention text='@John Doe' indicator='@' id='1'>@John Doe</mention>"),
"<mention id=\"1\" text=\"@John Doe\" indicator=\"@\">@John "
"<mention text=\"@John Doe\" indicator=\"@\" id=\"1\">@John "
"Doe</mention>");
EXPECT_EQ(
GumboParser::normalizeHtml("<mention text=\"@John Doe\" indicator=\"@\" "
"id=\"1\">@John Doe</mention>"),
"<mention id=\"1\" text=\"@John Doe\" indicator=\"@\">@John "
"<mention text=\"@John Doe\" indicator=\"@\" id=\"1\">@John "
"Doe</mention>");
// Custom mention attributes are preserved
EXPECT_EQ(
GumboParser::normalizeHtml(
"<mention id=\"1\" text=\"@John Doe\" indicator=\"@\" type=\"user\" "
"data-custom=\"custom data\">@John Doe</mention>"),
"<mention id=\"1\" text=\"@John Doe\" indicator=\"@\" type=\"user\" "
"data-custom=\"custom data\">@John Doe</mention>");

// Link
EXPECT_EQ(GumboParser::normalizeHtml(
Expand Down
2 changes: 1 addition & 1 deletion docs/INPUT_API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1041,7 +1041,7 @@ interface MentionStyleProperties {

### mention

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

- `color` defines the color of mention's text, takes [color](https://reactnative.dev/docs/colors) value and defaults to `blue`.
- `backgroundColor` is the mention's background color, takes [color](https://reactnative.dev/docs/colors) value and defaults to `yellow`.
Expand Down
37 changes: 36 additions & 1 deletion docs/WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,39 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo

## HTML sanitization

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

- **`EnrichedText`** sanitizes its `children` before rendering.
- **`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.

### Allowing custom link protocols

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.

`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:

```tsx
<EnrichedText
sanitizationConfig={{
// Permit the usual protocols plus a custom "custom://" scheme.
linkRegex:
/^(?:(?:(?:f|ht)tps?|mailto|tel|custom):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i,
}}
>
{html}
</EnrichedText>
```

When `sanitizationConfig` is omitted, DOMPurify's built-in default is used.

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

### Custom mention attributes

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.

## Client-only rendering (no SSR)

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

If your application uses SSR (Next.js, Remix, Gatsby, etc.), make sure these components only render on the client.
33 changes: 33 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,25 @@ export interface OnChangeMentionEvent {
text: string;
}

/**
* Web-only configuration for the HTML sanitization step.
*
* @platform web
*/
export interface SanitizationConfig {
/**
* Regular expression used to decide which link URIs survive sanitization.
* Maps directly to DOMPurify's `ALLOWED_URI_REGEXP`, so it fully replaces
* the default allow-list rather than extending it — include the standard
* protocols you still want to permit in addition to any custom ones.
*
* When omitted, DOMPurify's built-in default is used.
*
* @platform web
*/
linkRegex?: RegExp;
}

/**
* Props for the `<EnrichedTextInput />` rich-text editor component.
*/
Expand Down Expand Up @@ -762,6 +781,13 @@ export interface EnrichedTextInputProps extends Omit<ViewProps, 'children'> {
*/
useHtmlNormalizer?: boolean;

/**
* Web-only configuration for the HTML sanitization step.
*
* @platform web
*/
sanitizationConfig?: SanitizationConfig;

/**
* If true, fonts will scale to respect the system's accessibility text size.
* Enabled by default.
Expand Down Expand Up @@ -804,6 +830,13 @@ export interface EnrichedTextProps extends ViewProps {
*/
useHtmlNormalizer?: boolean;

/**
* Web-only configuration for the HTML sanitization step.
*
* @platform web
*/
sanitizationConfig?: SanitizationConfig;

/**
* How to truncate text when it overflows `numberOfLines`.
* - `"head"` — truncates the beginning.
Expand Down
9 changes: 8 additions & 1 deletion src/web/EnrichedText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useImageErrorFallback } from './useImageErrorFallback';
import { usePressInteractions } from './usePressInteractions';
import { adaptWebToNativeEvent } from './adaptWebToNativeEvent';
import { useStableRef } from './useStableRef';
import { assertBrowserEnvironment } from './assertBrowserEnvironment';

export const EnrichedText = memo(
({
Expand All @@ -30,11 +31,14 @@ export const EnrichedText = memo(
selectionColor,
selectable = false,
useHtmlNormalizer = true,
sanitizationConfig,
onFocus,
onBlur,
onLinkPress,
onMentionPress,
}: EnrichedTextProps) => {
assertBrowserEnvironment('EnrichedText');

const containerRef = useRef<HTMLDivElement>(null);

useImperativeHandle(ref, () => ({
Expand All @@ -50,7 +54,10 @@ export const EnrichedText = memo(
},
}));

const sanitizedHtml = useMemo(() => sanitizeHtml(children), [children]);
const sanitizedHtml = useMemo(
() => sanitizeHtml(children, sanitizationConfig),
[children, sanitizationConfig]
);

const finalHtml = useMemo(
() => prepareHtmlForWeb(sanitizedHtml, useHtmlNormalizer),
Expand Down
49 changes: 42 additions & 7 deletions src/web/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ import { returnKeyTypeToEnterKeyHint } from './returnKeyTypeToEnterKeyHint';
import { ENRICHED_TEXT_INPUT_CLASSNAME } from './constants/classNames';
import { AutolinkPlugin } from './pmPlugins/AutolinkPlugin';
import { useStableRef } from './useStableRef';
import {
checkMentionAttributes,
sanitizeMentionAttributes,
} from './sanitization/htmlSanitizer';
import { assertBrowserEnvironment } from './assertBrowserEnvironment';

function runFocused(
editor: Editor,
Expand Down Expand Up @@ -121,11 +126,18 @@ export const EnrichedTextInput = ({
linkRegex,
htmlStyle,
useHtmlNormalizer = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.useHtmlNormalizer,
sanitizationConfig,
textShortcuts = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.textShortcuts,
}: EnrichedTextInputProps) => {
assertBrowserEnvironment('EnrichedTextInput');

const tiptapContent =
defaultValue != null
? prepareHtmlForTiptap(defaultValue, useHtmlNormalizer)
? prepareHtmlForTiptap(
defaultValue,
useHtmlNormalizer,
sanitizationConfig
)
: defaultValue;

const resolvedHtmlStyle = useMemo(
Expand All @@ -149,6 +161,7 @@ export const EnrichedTextInput = ({
const onSubmitEditingRef = useStableRef(onSubmitEditing);
const onKeyPressRef = useStableRef(onKeyPress);
const useHtmlNormalizerRef = useStableRef(useHtmlNormalizer);
const sanitizationConfigRef = useStableRef(sanitizationConfig);
const mentionCallbacksRef = useStableRef(mentionCallbacks);
const textShortcutsRef = useStableRef(textShortcuts);

Expand Down Expand Up @@ -274,7 +287,11 @@ export const EnrichedTextInput = ({
enterkeyhint: returnKeyTypeToEnterKeyHint(returnKeyType),
},
transformPastedHTML: (html) => {
return prepareHtmlForTiptap(html, useHtmlNormalizerRef.current);
return prepareHtmlForTiptap(
html,
useHtmlNormalizerRef.current,
sanitizationConfigRef.current
);
},
},
},
Expand Down Expand Up @@ -309,7 +326,7 @@ export const EnrichedTextInput = ({
);

useMentionEvents(editor, getMentionCallbacks);
useOnChangeHtml(editor, onChangeHtml);
useOnChangeHtml(editor, onChangeHtml, sanitizationConfig);
useOnChangeText(editor, onChangeText);
useOnChangeState(editor, resolvedHtmlStyle, onChangeState);
useOnLinkDetected(editor, linkEmitterRef);
Expand All @@ -321,7 +338,11 @@ export const EnrichedTextInput = ({
blur: () => editor.commands.blur(),
setValue: (value: string) =>
editor.commands.setContent(
prepareHtmlForTiptap(value, useHtmlNormalizerRef.current)
prepareHtmlForTiptap(
value,
useHtmlNormalizerRef.current,
sanitizationConfigRef.current
)
),
setSelection: (start, end) => {
const doc = editor.state.doc;
Expand All @@ -332,7 +353,13 @@ export const EnrichedTextInput = ({
})
);
},
getHTML: () => Promise.resolve(normalizeHtmlFromTiptap(editor.getHTML())),
getHTML: () =>
Promise.resolve(
normalizeHtmlFromTiptap(
editor.getHTML(),
sanitizationConfigRef.current
)
),
toggleBold: () => runFocused(editor, (c) => c.toggleBold()),
toggleItalic: () => runFocused(editor, (c) => c.toggleItalic()),
toggleUnderline: () => runFocused(editor, (c) => c.toggleUnderline()),
Expand Down Expand Up @@ -362,7 +389,15 @@ export const EnrichedTextInput = ({
indicator: string,
text: string,
attributes?: Record<string, string>
) => setMention(editor, indicator, text, attributes),
) => {
checkMentionAttributes(attributes);
setMention(
editor,
indicator,
text,
sanitizeMentionAttributes(attributes)
);
},
setImage: (src: string, width: number, height: number) =>
runFocused(editor, (c) => c.setImage({ src, width, height })),
measure: () => {},
Expand All @@ -377,7 +412,7 @@ export const EnrichedTextInput = ({
}
},
}),
[editor, mentionIndicatorsRef, useHtmlNormalizerRef]
[editor, mentionIndicatorsRef, useHtmlNormalizerRef, sanitizationConfigRef]
);

const editorStyle: CSSProperties = useMemo(
Expand Down
8 changes: 8 additions & 0 deletions src/web/__tests__/assertBrowserEnvironment.dom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { assertBrowserEnvironment } from '../assertBrowserEnvironment';

describe('assertBrowserEnvironment', () => {
// jsdom provides a full DOM, so the browser APIs are present by default.
test('does not throw when the DOM globals are available', () => {
expect(() => assertBrowserEnvironment('EnrichedText')).not.toThrow();
});
});
13 changes: 13 additions & 0 deletions src/web/__tests__/assertBrowserEnvironment.ssr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* @jest-environment node
*/
// Because of the docblock above, jsdom test environment does not exist here
import { assertBrowserEnvironment } from '../assertBrowserEnvironment';

describe('assertBrowserEnvironment', () => {
test('throws when DOM is missing', () => {
expect(() => assertBrowserEnvironment('EnrichedText')).toThrow(
/client-only/
);
});
});
Loading
Loading