diff --git a/.playwright/tests/links.spec.ts b/.playwright/tests/links.spec.ts
index 60402572d..0300131ed 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 929d0dbea..51b1dc31e 100644
--- a/apps/example-web/src/App.tsx
+++ b/apps/example-web/src/App.tsx
@@ -121,16 +121,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 e2311a6f0..dbf9cb4d6 100644
--- a/cpp/parser/GumboNormalizer.c
+++ b/cpp/parser/GumboNormalizer.c
@@ -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);
diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp
index 0f5586969..323546064 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 2597c24d0..363c095bc 100644
--- a/docs/INPUT_API_REFERENCE.md
+++ b/docs/INPUT_API_REFERENCE.md
@@ -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`.
diff --git a/docs/WEB.md b/docs/WEB.md
index 382d26f48..b4816f543 100644
--- a/docs/WEB.md
+++ b/docs/WEB.md
@@ -51,4 +51,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 0c59e88d9..0c5319aa5 100644
--- a/src/web/EnrichedTextInput.tsx
+++ b/src/web/EnrichedTextInput.tsx
@@ -80,6 +80,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,
@@ -355,7 +359,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 e2ebcf3ed..91b76b055 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 a7ed7f1e7..7b9492152 100644
--- a/src/web/normalization/htmlNormalizer.ts
+++ b/src/web/normalization/htmlNormalizer.ts
@@ -286,11 +286,11 @@ function emitAttributes(el: Element, name: string): string {
el.getAttribute('data-leveltext') === ''; // MS Word checked box
return isChecked ? ' checked' : '';
case 'mention':
- return (
- emitOneAttr(el, 'id') +
- emitOneAttr(el, 'text') +
- emitOneAttr(el, 'indicator')
- );
+ let out = '';
+ for (const attr of Array.from(el.attributes)) {
+ out += emitOneAttr(el, attr.name);
+ }
+ return out;
default:
// preserve text-align
return emitAlignment(el, name);
diff --git a/src/web/normalization/tiptapHtmlNormalizer.ts b/src/web/normalization/tiptapHtmlNormalizer.ts
index 5831db4ac..7f875276d 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.`
+ );
});
}