Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
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
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');
});
});
Comment thread
hejsztynx marked this conversation as resolved.

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');
});
Comment thread
hejsztynx marked this conversation as resolved.
});
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.
Outdated
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.
Outdated

// 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