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');
});
});
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/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/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md
index 52af2af56..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.
+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 0d729a6df..97111400a 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. 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.
+
+### 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/__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', () => {
'',
],
- // 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/__tests__/sanitization.test.ts b/src/web/__tests__/sanitization.test.ts
new file mode 100644
index 000000000..5ae9678fd
--- /dev/null
+++ b/src/web/__tests__/sanitization.test.ts
@@ -0,0 +1,113 @@
+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('sanitizeHtmlMention', () => {
+ 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');
+ });
+});
+
+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');
+ });
+});
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 '';
}
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..d1be388e7 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.`
+ );
});
}