From 05d5b22f4a3475ad00515263e8f7570330822765 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Sun, 5 Jul 2026 15:56:50 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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 ''; }