Skip to content
Open
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
8 changes: 4 additions & 4 deletions apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Expand Down
7 changes: 4 additions & 3 deletions cpp/parser/GumboNormalizer.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

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 @@ -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`.
Expand Down
9 changes: 8 additions & 1 deletion docs/WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 13 additions & 1 deletion src/web/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -351,7 +355,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 Down
11 changes: 8 additions & 3 deletions src/web/__tests__/htmlNormalizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,19 @@ describe('htmlNormalizer', () => {
'<ul data-type="checkbox"><li checked>x</li></ul>',
],

// Mentions (note: cpp reorders attrs to id, text, indicator)
// Mentions
[
"<mention text='@John Doe' indicator='@' id='1'>@John Doe</mention>",
'<mention id="1" text="@John Doe" indicator="@">@John Doe</mention>',
'<mention text="@John Doe" indicator="@" id="1">@John Doe</mention>',
],
[
'<mention text="@John Doe" indicator="@" id="1">@John Doe</mention>',
'<mention id="1" text="@John Doe" indicator="@">@John Doe</mention>',
'<mention text="@John Doe" indicator="@" id="1">@John Doe</mention>',
],
// Custom mention attributes are preserved
[
'<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
Expand Down
113 changes: 113 additions & 0 deletions src/web/__tests__/sanitization.test.ts
Original file line number Diff line number Diff line change
@@ -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 <mention> tags with text/indicator/data-* attributes', () => {
const out = sanitizeHtml(
'<mention text="Joe" indicator="@" data-user-id="42">@Joe</mention>'
);
expect(out).toContain('text="Joe"');
expect(out).toContain('indicator="@"');
expect(out).toContain('data-user-id="42"');
});

it('strips <mention> event handlers', () => {
expect(
sanitizeHtml('<mention onclick="alert(1)">x</mention>')
).not.toContain('onclick');
});
});

describe('sanitizeLinkAttributes', () => {
it('strips javascript: URLs from links', () => {
const out = sanitizeHtml('<a href="javascript:alert(1)">x</a>');
// eslint-disable-next-line no-script-url
expect(out).not.toContain('javascript:');
});

it('strips unknown protocol URLs from links', () => {
const out = sanitizeHtml('<a href="custom://link">x</a>');
expect(out).not.toContain('custom');
});
});
13 changes: 7 additions & 6 deletions src/web/normalization/htmlNormalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
}
Expand Down
3 changes: 3 additions & 0 deletions src/web/normalization/tiptapHtmlNormalizer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { sanitizeHtml } from '../sanitization/htmlSanitizer';
import {
checkboxHtmlForTiptap,
checkboxHtmlFromTiptap,
Expand All @@ -8,6 +9,7 @@ export function prepareHtmlForTiptap(
html: string,
useHtmlNormalizer: boolean | undefined
): string {
html = sanitizeHtml(html);
Comment thread
hejsztynx marked this conversation as resolved.
if (useHtmlNormalizer) {
html = normalizeHtml(html);
}
Expand All @@ -17,6 +19,7 @@ export function prepareHtmlForTiptap(
}

export function normalizeHtmlFromTiptap(html: string): string {
html = sanitizeHtml(html);
html = checkboxHtmlFromTiptap(html);
Comment thread
hejsztynx marked this conversation as resolved.

// Strip <p> wrappers inside <li> elements.
Expand Down
56 changes: 55 additions & 1 deletion src/web/sanitization/htmlSanitizer.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
): Record<string, string> {
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<string, string> = {};
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<string, string>) {
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 <mention> tag may be removed during sanitization. ` +
`Consider using the "data-" prefix for custom data attributes.`
);
});
}
Loading