From 05d5b22f4a3475ad00515263e8f7570330822765 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Sun, 5 Jul 2026 15:56:50 +0200 Subject: [PATCH 01/11] feat(web): mention sanitization --- docs/WEB.md | 9 ++- src/web/EnrichedTextInput.tsx | 14 ++++- src/web/normalization/tiptapHtmlNormalizer.ts | 3 + src/web/sanitization/htmlSanitizer.ts | 56 ++++++++++++++++++- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/docs/WEB.md b/docs/WEB.md index 0d729a6df..81ebcd2bf 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -49,4 +49,11 @@ 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: + +- **`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. + +### 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. diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 750829052..1cd49d6a5 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -78,6 +78,10 @@ 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'; function runFocused( editor: Editor, @@ -351,7 +355,15 @@ export const EnrichedTextInput = ({ indicator: string, text: string, attributes?: Record - ) => 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: () => {}, diff --git a/src/web/normalization/tiptapHtmlNormalizer.ts b/src/web/normalization/tiptapHtmlNormalizer.ts index 9ebd2c653..8aa3082d5 100644 --- a/src/web/normalization/tiptapHtmlNormalizer.ts +++ b/src/web/normalization/tiptapHtmlNormalizer.ts @@ -1,3 +1,4 @@ +import { sanitizeHtml } from '../sanitization/htmlSanitizer'; import { checkboxHtmlForTiptap, checkboxHtmlFromTiptap, @@ -8,6 +9,7 @@ export function prepareHtmlForTiptap( html: string, useHtmlNormalizer: boolean | undefined ): string { + html = sanitizeHtml(html); if (useHtmlNormalizer) { html = normalizeHtml(html); } @@ -17,6 +19,7 @@ export function prepareHtmlForTiptap( } export function normalizeHtmlFromTiptap(html: string): string { + html = sanitizeHtml(html); html = checkboxHtmlFromTiptap(html); // Strip

wrappers inside

  • elements. diff --git a/src/web/sanitization/htmlSanitizer.ts b/src/web/sanitization/htmlSanitizer.ts index 7a45adc19..079167edf 100644 --- a/src/web/sanitization/htmlSanitizer.ts +++ b/src/web/sanitization/htmlSanitizer.ts @@ -1,8 +1,62 @@ import DOMPurify from 'dompurify'; +const MENTION_ATTRS = ['text', 'indicator']; + +// Attributes DOMPurify keeps by default and are commonly used, so we don't emit an unnecessary warning +const COMMONLY_ALLOWED_ATTRS = ['id', 'class', 'style']; + export function sanitizeHtml(html: string) { return DOMPurify.sanitize(html, { ADD_TAGS: ['mention', 'codeblock'], - ADD_ATTR: ['text', 'indicator'], + ADD_ATTR: MENTION_ATTRS, + }); +} + +export function sanitizeMentionAttributes( + attributes?: Record +): Record { + if (!attributes) return {}; + + const el = document.createElement('mention'); + for (const [name, value] of Object.entries(attributes)) { + try { + el.setAttribute(name, value); + } catch { + // Ignore invalid attribute names. + } + } + + const cleaned = new DOMParser() + .parseFromString(sanitizeHtml(el.outerHTML), 'text/html') + .querySelector('mention'); + + const out: Record = {}; + if (!cleaned) return out; + + for (const attr of Array.from(cleaned.attributes)) { + if (MENTION_ATTRS.includes(attr.name.toLowerCase())) continue; + out[attr.name] = attr.value; + } + return out; +} + +// Runtime warning: custom attributes without a "data-" prefix may be +// removed by sanitization. This is a heuristic (it does not run DOMPurify). +export function checkMentionAttributes(attributes?: Record) { + if (!attributes) return; + + Object.keys(attributes).forEach((attrName) => { + const lower = attrName.toLowerCase(); + if ( + lower.startsWith('data-') || + MENTION_ATTRS.includes(lower) || + COMMONLY_ALLOWED_ATTRS.includes(lower) + ) { + return; + } + console.warn( + `[EnrichedMention] Attribute "${attrName}" on the tag may be removed during sanitization. ` + + `Consider using the "data-" prefix for custom data attributes (e.g., "data-${attrName}").` + ); }); } From 6ae68b2f78737afc003f631b2b350d1a400017c3 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Sun, 5 Jul 2026 18:43:09 +0200 Subject: [PATCH 02/11] feat: mention sanitization tests --- src/web/__tests__/mentionSanitization.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/web/__tests__/mentionSanitization.test.ts diff --git a/src/web/__tests__/mentionSanitization.test.ts b/src/web/__tests__/mentionSanitization.test.ts new file mode 100644 index 000000000..bfee41008 --- /dev/null +++ b/src/web/__tests__/mentionSanitization.test.ts @@ -0,0 +1,100 @@ +import { + sanitizeHtml, + sanitizeMentionAttributes, + checkMentionAttributes, +} from '../sanitization/htmlSanitizer'; + +describe('sanitizeMentionAttributes', () => { + it('returns an empty object when given no attributes', () => { + expect(sanitizeMentionAttributes()).toEqual({}); + expect(sanitizeMentionAttributes({})).toEqual({}); + }); + + it('keeps data-* and commonly-allowed attributes', () => { + expect( + sanitizeMentionAttributes({ + 'data-user-id': '42', + 'data-team': 'core', + 'id': 'm1', + 'class': 'highlight', + }) + ).toEqual({ + 'data-user-id': '42', + 'data-team': 'core', + 'id': 'm1', + 'class': 'highlight', + }); + }); + + it('strips event handlers and unsafe attributes', () => { + const result = sanitizeMentionAttributes({ + 'onclick': 'alert(1)', + 'onmouseover': 'steal()', + // eslint-disable-next-line no-script-url + 'href': 'javascript:alert(1)', + 'data-user-id': '42', + }); + expect(result).toEqual({ 'data-user-id': '42' }); + }); + + it('does not return the reserved text/indicator attributes', () => { + const result = sanitizeMentionAttributes({ + 'text': 'Joe', + 'indicator': '@', + 'data-user-id': '42', + }); + expect(result).toEqual({ 'data-user-id': '42' }); + }); +}); + +describe('checkMentionAttributes', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('does not warn for data-*, text, indicator, or commonly-allowed attributes', () => { + checkMentionAttributes({ + 'data-user-id': '42', + 'text': 'Joe', + 'indicator': '@', + 'id': 'm1', + 'class': 'x', + 'style': 'color: red', + }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns for custom attributes without a recognized prefix', () => { + checkMentionAttributes({ foo: 'bar' }); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('foo'); + }); + + it('does nothing when given no attributes', () => { + checkMentionAttributes(); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe('sanitizeHtml', () => { + it('keeps tags with text/indicator/data-* attributes', () => { + const out = sanitizeHtml( + '@Joe' + ); + expect(out).toContain('text="Joe"'); + expect(out).toContain('indicator="@"'); + expect(out).toContain('data-user-id="42"'); + }); + + it('strips event handlers', () => { + expect( + sanitizeHtml('x') + ).not.toContain('onclick'); + }); +}); From d505d54146d8d8548bbaae23a4137008198fad2d Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Sun, 5 Jul 2026 19:46:48 +0200 Subject: [PATCH 03/11] docs: default mention style documentation --- docs/INPUT_API_REFERENCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index 52af2af56..e2ad1a924 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -1043,7 +1043,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 a keys and configs as their values. You can also 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`. From 7eb3c09969db4892c08031e75a377e068aca4e4b Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 6 Jul 2026 12:12:35 +0200 Subject: [PATCH 04/11] test: fix obsolete tests --- .playwright/tests/links.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.playwright/tests/links.spec.ts b/.playwright/tests/links.spec.ts index 6a9ab7641..f7352a0c4 100644 --- a/.playwright/tests/links.spec.ts +++ b/.playwright/tests/links.spec.ts @@ -479,7 +479,7 @@ test.describe('test-links copy-paste', () => { await setTestLinksEditorHtml( page, - '

    custom://link

    ' + '

    /custom-link

    ' ); await copyWholeContent(editor); @@ -488,7 +488,7 @@ test.describe('test-links copy-paste', () => { await expect .poll(async () => getTestLinksSerializedHtml(page)) - .toContain('custom://link'); + .toContain('/custom-link'); }); }); From 543de3064c1be473fd701f06993bc8ef9d2ff43b Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 6 Jul 2026 13:38:32 +0200 Subject: [PATCH 05/11] feat: example app update --- apps/example-web/src/App.tsx | 8 ++++---- src/web/sanitization/htmlSanitizer.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 8fa6e035a..3a8b9710d 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -123,16 +123,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(); }; diff --git a/src/web/sanitization/htmlSanitizer.ts b/src/web/sanitization/htmlSanitizer.ts index 079167edf..d1be388e7 100644 --- a/src/web/sanitization/htmlSanitizer.ts +++ b/src/web/sanitization/htmlSanitizer.ts @@ -56,7 +56,7 @@ export function checkMentionAttributes(attributes?: Record) { } console.warn( `[EnrichedMention] Attribute "${attrName}" on the tag may be removed during sanitization. ` + - `Consider using the "data-" prefix for custom data attributes (e.g., "data-${attrName}").` + `Consider using the "data-" prefix for custom data attributes.` ); }); } From 0263e545cc14cbbad24f52827f5781a5cfd679aa Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 6 Jul 2026 14:13:41 +0200 Subject: [PATCH 06/11] docs: cleanup --- docs/INPUT_API_REFERENCE.md | 2 +- docs/WEB.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index e2ad1a924..b5ef8f612 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -1043,7 +1043,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. You can also define a style using the `'default'` key, which will act as a base that the rest of your defined styles will fallback on. +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`. diff --git a/docs/WEB.md b/docs/WEB.md index 81ebcd2bf..97111400a 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -49,7 +49,7 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo ## HTML sanitization -On web, HTML is sanitized automatically with [DOMPurify](https://github.com/cure53/DOMPurify) on both input and output: +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. From a95a17cc00109806f330228a7d0d495b05e89c5f Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 6 Jul 2026 14:17:17 +0200 Subject: [PATCH 07/11] test: link sanitization tests --- ...nSanitization.test.ts => sanitization.test.ts} | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) rename src/web/__tests__/{mentionSanitization.test.ts => sanitization.test.ts} (85%) diff --git a/src/web/__tests__/mentionSanitization.test.ts b/src/web/__tests__/sanitization.test.ts similarity index 85% rename from src/web/__tests__/mentionSanitization.test.ts rename to src/web/__tests__/sanitization.test.ts index bfee41008..5ae9678fd 100644 --- a/src/web/__tests__/mentionSanitization.test.ts +++ b/src/web/__tests__/sanitization.test.ts @@ -82,7 +82,7 @@ describe('checkMentionAttributes', () => { }); }); -describe('sanitizeHtml', () => { +describe('sanitizeHtmlMention', () => { it('keeps tags with text/indicator/data-* attributes', () => { const out = sanitizeHtml( '@Joe' @@ -98,3 +98,16 @@ describe('sanitizeHtml', () => { ).not.toContain('onclick'); }); }); + +describe('sanitizeLinkAttributes', () => { + it('strips javascript: URLs from links', () => { + const out = sanitizeHtml('x'); + // eslint-disable-next-line no-script-url + expect(out).not.toContain('javascript:'); + }); + + it('strips unknown protocol URLs from links', () => { + const out = sanitizeHtml('x'); + expect(out).not.toContain('custom'); + }); +}); From 726b7ea9992260848cc3a5a5afe312f612779e21 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Tue, 7 Jul 2026 23:19:31 +0200 Subject: [PATCH 08/11] feat: pass all mention attrs in normalization process --- cpp/parser/GumboNormalizer.c | 7 ++++--- cpp/tests/GumboParserTest.cpp | 11 +++++++++-- src/web/__tests__/htmlNormalizer.test.ts | 11 ++++++++--- src/web/normalization/htmlNormalizer.ts | 13 +++++++------ 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index c4ece832b..69011d135 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -428,9 +428,10 @@ static void emit_attributes(GumboElement *el, const char *tag_name, if (gumbo_get_attribute(&el->attributes, "checked") != NULL) 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); + } } } diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index f45b7ec93..06235d5b7 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -307,13 +307,20 @@ TEST(GumboParserTest, EnrichedTagRemappings) { EXPECT_EQ( GumboParser::normalizeHtml( "@John Doe"), - "@John " + "@John " "Doe"); EXPECT_EQ( GumboParser::normalizeHtml("@John Doe"), - "@John " + "@John " "Doe"); + // Custom mention attributes are preserved + EXPECT_EQ( + GumboParser::normalizeHtml( + "@John Doe"), + "@John Doe"); // Link EXPECT_EQ(GumboParser::normalizeHtml( diff --git a/src/web/__tests__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts index 3802e54f6..6d3b2d398 100644 --- a/src/web/__tests__/htmlNormalizer.test.ts +++ b/src/web/__tests__/htmlNormalizer.test.ts @@ -285,14 +285,19 @@ describe('htmlNormalizer', () => { '
    • x
    ', ], - // Mentions (note: cpp reorders attrs to id, text, indicator) + // Mentions [ "@John Doe", - '@John Doe', + '@John Doe', ], [ '@John Doe', - '@John Doe', + '@John Doe', + ], + // Custom mention attributes are preserved + [ + '@John Doe', + '@John Doe', ], // Link diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index be34e44f9..74c14be54 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -255,12 +255,13 @@ function emitAttributes(el: Element, name: string): string { } case 'li': return el.hasAttribute('checked') ? ' checked' : ''; - case 'mention': - return ( - emitOneAttr(el, 'id') + - emitOneAttr(el, 'text') + - emitOneAttr(el, 'indicator') - ); + case 'mention': { + let out = ''; + for (const attr of Array.from(el.attributes)) { + out += emitOneAttr(el, attr.name); + } + return out; + } default: return ''; } From 558947831636aca6868fe14a555b3c938ae20398 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz <146986839+hejsztynx@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:06:46 +0200 Subject: [PATCH 09/11] feat(web): browser environment assertion (#707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Summary Both components `EnrichedText` and `EnrichedTextInput` are using DOM - `DOMPurify`, `DOMParser`, `TipTap`. An attempt of rendering them in a non-browser environment, e.g. in SSR applications, would result in a crash. Only `DOMParser` availability check was present, but it was silent. Now we check for the browser environment before any other component logic and throw a proper error. Documentation was also updated, to explicitly point the client-only nature of the components. ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ❌ | | Web | ✅ | ## Checklist - [x] E2E tests are passing - [ ] Required E2E tests have been added (if applicable) --- docs/WEB.md | 6 ++++++ src/web/EnrichedText.tsx | 3 +++ src/web/EnrichedTextInput.tsx | 3 +++ .../assertBrowserEnvironment.dom.test.ts | 8 +++++++ .../assertBrowserEnvironment.ssr.test.ts | 13 ++++++++++++ src/web/assertBrowserEnvironment.ts | 21 +++++++++++++++++++ src/web/normalization/htmlNormalizer.ts | 2 -- src/web/normalization/prepareHtmlForWeb.ts | 2 -- 8 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 src/web/__tests__/assertBrowserEnvironment.dom.test.ts create mode 100644 src/web/__tests__/assertBrowserEnvironment.ssr.test.ts create mode 100644 src/web/assertBrowserEnvironment.ts diff --git a/docs/WEB.md b/docs/WEB.md index b4816f543..ec0c0f9a8 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -59,3 +59,9 @@ On web, HTML is sanitized automatically with [DOMPurify](https://github.com/cure ### 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. diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index 63141b0d7..5ee869a99 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -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( ({ @@ -35,6 +36,8 @@ export const EnrichedText = memo( onLinkPress, onMentionPress, }: EnrichedTextProps) => { + assertBrowserEnvironment('EnrichedText'); + const containerRef = useRef(null); useImperativeHandle(ref, () => ({ diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 0c5319aa5..9d6895e7f 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -84,6 +84,7 @@ import { checkMentionAttributes, sanitizeMentionAttributes, } from './sanitization/htmlSanitizer'; +import { assertBrowserEnvironment } from './assertBrowserEnvironment'; function runFocused( editor: Editor, @@ -125,6 +126,8 @@ export const EnrichedTextInput = ({ htmlStyle, useHtmlNormalizer = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.useHtmlNormalizer, }: EnrichedTextInputProps) => { + assertBrowserEnvironment('EnrichedTextInput'); + const tiptapContent = defaultValue != null ? prepareHtmlForTiptap(defaultValue, useHtmlNormalizer) diff --git a/src/web/__tests__/assertBrowserEnvironment.dom.test.ts b/src/web/__tests__/assertBrowserEnvironment.dom.test.ts new file mode 100644 index 000000000..5bb67f5d1 --- /dev/null +++ b/src/web/__tests__/assertBrowserEnvironment.dom.test.ts @@ -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(); + }); +}); diff --git a/src/web/__tests__/assertBrowserEnvironment.ssr.test.ts b/src/web/__tests__/assertBrowserEnvironment.ssr.test.ts new file mode 100644 index 000000000..6035147be --- /dev/null +++ b/src/web/__tests__/assertBrowserEnvironment.ssr.test.ts @@ -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/ + ); + }); +}); diff --git a/src/web/assertBrowserEnvironment.ts b/src/web/assertBrowserEnvironment.ts new file mode 100644 index 000000000..3fa03721b --- /dev/null +++ b/src/web/assertBrowserEnvironment.ts @@ -0,0 +1,21 @@ +/** + * `EnrichedText` and `EnrichedTextInput` rely on browser-only APIs (DOMParser, + * DOMPurify, TipTap) and therefore cannot render without a DOM — e.g. during + * server-side rendering (SSR). They are client-only components. + * + * This asserts a DOM is available and throws a clear error otherwise. + */ +export function assertBrowserEnvironment(componentName: string): void { + const hasDOM = + typeof window !== 'undefined' && + typeof document !== 'undefined' && + typeof DOMParser !== 'undefined' && + typeof Node !== 'undefined'; + + if (!hasDOM) { + throw new Error( + `[react-native-enriched-html] ${componentName} is a client-only component and cannot be rendered without a DOM. ` + + `If you are running an SSR application, make sure the component is only rendered on the client.` + ); + } +} diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index 7b9492152..22ecd3236 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -679,8 +679,6 @@ function walkNode(node: Node, out: { buf: string }): void { } export function normalizeHtml(html: string): string { - if (typeof DOMParser === 'undefined') return html; - const parser = new DOMParser(); const doc = parser.parseFromString(`${html}`, 'text/html'); const body = doc.body; diff --git a/src/web/normalization/prepareHtmlForWeb.ts b/src/web/normalization/prepareHtmlForWeb.ts index 99c315429..8da6d63e8 100644 --- a/src/web/normalization/prepareHtmlForWeb.ts +++ b/src/web/normalization/prepareHtmlForWeb.ts @@ -4,8 +4,6 @@ export function prepareHtmlForWeb( html: string, useHtmlNormalizer: boolean | undefined ): string { - if (typeof DOMParser === 'undefined') return html; - if (useHtmlNormalizer) { html = normalizeHtml(html); } From 0307d128279b91d40511ed1e34e690c3b7bc4357 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 27 Jul 2026 12:57:03 +0200 Subject: [PATCH 10/11] feat: implement sanitization config prop --- apps/example-web/src/App.tsx | 3 ++ .../src/components/TextRenderer.tsx | 6 +++ docs/WEB.md | 22 +++++++++++ src/types.ts | 33 +++++++++++++++++ src/web/EnrichedText.tsx | 6 ++- src/web/EnrichedTextInput.tsx | 32 +++++++++++++--- src/web/__tests__/sanitization.test.ts | 37 +++++++++++++++++++ src/web/normalization/tiptapHtmlNormalizer.ts | 13 +++++-- src/web/sanitization/htmlSanitizer.ts | 5 ++- src/web/useOnChangeHtml.ts | 7 ++-- 10 files changed, 149 insertions(+), 15 deletions(-) diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 51b1dc31e..9d65fca0c 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -278,6 +278,9 @@ function App() { mentionIndicators={['@', '#']} htmlStyle={WEB_DEFAULT_HTML_STYLE} linkRegex={LINK_REGEX} + sanitizationConfig={{ + linkRegex: LINK_REGEX, + }} /> {htmlValue} diff --git a/docs/WEB.md b/docs/WEB.md index b495bb2ce..f8267a710 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -56,6 +56,28 @@ On web, HTML is sanitized automatically with [DOMPurify](https://github.com/cure - **`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 + + {html} + +``` + +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. diff --git a/src/types.ts b/src/types.ts index acda85b41..d4d0dfd8b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 `` rich-text editor component. */ @@ -762,6 +781,13 @@ export interface EnrichedTextInputProps extends Omit { */ 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. @@ -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. diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index 5ee869a99..8ec916cc5 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -31,6 +31,7 @@ export const EnrichedText = memo( selectionColor, selectable = false, useHtmlNormalizer = true, + sanitizationConfig, onFocus, onBlur, onLinkPress, @@ -53,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), diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index be8f54d02..87fa483b2 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -126,13 +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( @@ -156,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); @@ -281,7 +287,11 @@ export const EnrichedTextInput = ({ enterkeyhint: returnKeyTypeToEnterKeyHint(returnKeyType), }, transformPastedHTML: (html) => { - return prepareHtmlForTiptap(html, useHtmlNormalizerRef.current); + return prepareHtmlForTiptap( + html, + useHtmlNormalizerRef.current, + sanitizationConfigRef.current + ); }, }, }, @@ -316,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); @@ -328,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; @@ -339,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()), @@ -392,7 +412,7 @@ export const EnrichedTextInput = ({ } }, }), - [editor, mentionIndicatorsRef, useHtmlNormalizerRef] + [editor, mentionIndicatorsRef, useHtmlNormalizerRef, sanitizationConfigRef] ); const editorStyle: CSSProperties = useMemo( diff --git a/src/web/__tests__/sanitization.test.ts b/src/web/__tests__/sanitization.test.ts index 5ae9678fd..2814432c8 100644 --- a/src/web/__tests__/sanitization.test.ts +++ b/src/web/__tests__/sanitization.test.ts @@ -111,3 +111,40 @@ describe('sanitizeLinkAttributes', () => { expect(out).not.toContain('custom'); }); }); + +describe('sanitizeHtml with a custom linkRegex', () => { + const linkRegex = + /^(?:(?:(?:f|ht)tps?|mailto|tel|custom):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i; + + it('keeps links with a whitelisted custom protocol', () => { + const out = sanitizeHtml('x', { + linkRegex, + }); + expect(out).toContain('href="custom://something"'); + }); + + it('still keeps standard protocols when a custom regex is supplied', () => { + const out = sanitizeHtml('x', { + linkRegex, + }); + expect(out).toContain('href="https://example.com"'); + }); + + it('still strips protocols not covered by the custom regex', () => { + const out = sanitizeHtml('x', { linkRegex }); + expect(out).not.toContain('other://'); + }); + + it('strips custom protocols when no config is supplied (default behavior)', () => { + const out = sanitizeHtml('x'); + expect(out).not.toContain('custom://'); + }); + + it('does not weaken javascript: stripping when a custom regex is supplied', () => { + const out = sanitizeHtml('x', { + linkRegex, + }); + // eslint-disable-next-line no-script-url + expect(out).not.toContain('javascript:'); + }); +}); diff --git a/src/web/normalization/tiptapHtmlNormalizer.ts b/src/web/normalization/tiptapHtmlNormalizer.ts index 7f875276d..976e1d316 100644 --- a/src/web/normalization/tiptapHtmlNormalizer.ts +++ b/src/web/normalization/tiptapHtmlNormalizer.ts @@ -1,4 +1,5 @@ import { sanitizeHtml } from '../sanitization/htmlSanitizer'; +import type { SanitizationConfig } from '../../types'; import { checkboxHtmlForTiptap, checkboxHtmlFromTiptap, @@ -7,9 +8,10 @@ import { normalizeHtml } from './htmlNormalizer'; export function prepareHtmlForTiptap( html: string, - useHtmlNormalizer: boolean | undefined + useHtmlNormalizer: boolean | undefined, + sanitizationConfig?: SanitizationConfig ): string { - html = sanitizeHtml(html); + html = sanitizeHtml(html, sanitizationConfig); if (useHtmlNormalizer) { html = normalizeHtml(html); } @@ -18,8 +20,11 @@ export function prepareHtmlForTiptap( return html; } -export function normalizeHtmlFromTiptap(html: string): string { - html = sanitizeHtml(html); +export function normalizeHtmlFromTiptap( + html: string, + sanitizationConfig?: SanitizationConfig +): string { + html = sanitizeHtml(html, sanitizationConfig); html = checkboxHtmlFromTiptap(html); // Strip

    wrappers inside

  • elements. diff --git a/src/web/sanitization/htmlSanitizer.ts b/src/web/sanitization/htmlSanitizer.ts index d1be388e7..a57a56aa0 100644 --- a/src/web/sanitization/htmlSanitizer.ts +++ b/src/web/sanitization/htmlSanitizer.ts @@ -1,14 +1,17 @@ import DOMPurify from 'dompurify'; +import type { SanitizationConfig } from '../../types'; const MENTION_ATTRS = ['text', 'indicator']; // Attributes DOMPurify keeps by default and are commonly used, so we don't emit an unnecessary warning const COMMONLY_ALLOWED_ATTRS = ['id', 'class', 'style']; -export function sanitizeHtml(html: string) { +export function sanitizeHtml(html: string, config?: SanitizationConfig) { return DOMPurify.sanitize(html, { ADD_TAGS: ['mention', 'codeblock'], ADD_ATTR: MENTION_ATTRS, + // if not supplied, fall back to DOMPurify's built-in default. + ...(config?.linkRegex ? { ALLOWED_URI_REGEXP: config.linkRegex } : {}), }); } diff --git a/src/web/useOnChangeHtml.ts b/src/web/useOnChangeHtml.ts index 90d159d97..a172d0655 100644 --- a/src/web/useOnChangeHtml.ts +++ b/src/web/useOnChangeHtml.ts @@ -1,14 +1,15 @@ import { type Editor } from '@tiptap/react'; -import type { OnChangeHtmlEvent } from '../types'; +import type { OnChangeHtmlEvent, SanitizationConfig } from '../types'; import type { NativeSyntheticEvent } from 'react-native'; import { useOnEditorChange } from './useOnEditorChange'; import { normalizeHtmlFromTiptap } from './normalization/tiptapHtmlNormalizer'; export const useOnChangeHtml = ( editor: Editor, - onChangeHtml?: (e: NativeSyntheticEvent) => void + onChangeHtml?: (e: NativeSyntheticEvent) => void, + sanitizationConfig?: SanitizationConfig ) => { useOnEditorChange(editor, onChangeHtml, (e) => - normalizeHtmlFromTiptap(e.getHTML()) + normalizeHtmlFromTiptap(e.getHTML(), sanitizationConfig) ); }; From 8c58f5a3e340c74548ce96619711390d7e717b72 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 27 Jul 2026 13:34:22 +0200 Subject: [PATCH 11/11] refactor: sanitizationConfig object extraction --- apps/example-web/src/App.tsx | 8 +++++--- apps/example-web/src/components/TextRenderer.tsx | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 9d65fca0c..245895ada 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -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(null); const [currentHtml, setCurrentHtml] = useState(''); @@ -278,9 +282,7 @@ function App() { mentionIndicators={['@', '#']} htmlStyle={WEB_DEFAULT_HTML_STYLE} linkRegex={LINK_REGEX} - sanitizationConfig={{ - linkRegex: LINK_REGEX, - }} + sanitizationConfig={SANITIZATION_CONFIG} /> {htmlValue}