diff --git a/changelog/entries/unreleased/bug/prevents_a_malicious_display_name_from_running_scripts_in_ri.json b/changelog/entries/unreleased/bug/prevents_a_malicious_display_name_from_running_scripts_in_ri.json new file mode 100644 index 0000000000..001a45a3b8 --- /dev/null +++ b/changelog/entries/unreleased/bug/prevents_a_malicious_display_name_from_running_scripts_in_ri.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Prevents a malicious display name from running scripts in rich text mentions", + "issue_origin": "github", + "issue_number": null, + "domain": "core", + "bullet_points": [], + "created_at": "2026-06-30" +} diff --git a/web-frontend/modules/core/editor/mention.js b/web-frontend/modules/core/editor/mention.js index 9fb4522d0b..46bf8b9fe4 100644 --- a/web-frontend/modules/core/editor/mention.js +++ b/web-frontend/modules/core/editor/mention.js @@ -3,6 +3,17 @@ import regexp from 'markdown-it-regexp' const USER_ID_REGEXP = /@(\d+)/ +// Escape user-controlled text before it's interpolated into the raw HTML +// string below, which is rendered with v-html. Without this, a malicious +// display name could inject markup and execute a stored XSS. +const escapeHtml = (value) => + String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + export const parseMention = (users, loggedUserId = null) => regexp(USER_ID_REGEXP, (match, utils) => { const user = users.find((user) => user.user_id === parseInt(match[1])) @@ -11,9 +22,10 @@ export const parseMention = (users, loggedUserId = null) => if (user.user_id === loggedUserId) { className += ' rich-text-editor__mention--current-user' } + const name = escapeHtml(user.name) // NOTE: Keep this in sync with the @tiptap/extension-mention // https://github.com/ueberdosis/tiptap/blob/main/packages/extension-mention/src/mention.ts - return `@${user.name}` + return `@${name}` } else { return `@${match[1]}` } diff --git a/web-frontend/test/unit/core/editor/mention.spec.js b/web-frontend/test/unit/core/editor/mention.spec.js new file mode 100644 index 0000000000..bfbb33c18e --- /dev/null +++ b/web-frontend/test/unit/core/editor/mention.spec.js @@ -0,0 +1,45 @@ +import { parseMarkdown } from '@baserow/modules/core/editor/markdown' + +describe('rich-text mention rendering', () => { + const XSS_PAYLOAD = '">' + + test('escapes a malicious workspace user name (stored XSS)', () => { + const users = [{ user_id: 1, name: XSS_PAYLOAD }] + + const html = parseMarkdown('Hello @1', { workspaceUsers: users }) + + // The payload must not survive as a live element, nor break out of the + // data-label attribute into the span's content. + expect(html).not.toContain(' { + const users = [{ user_id: 1, name: 'Jane Doe' }] + + const html = parseMarkdown('Hello @1', { workspaceUsers: users }) + + expect(html).toContain('data-label="Jane Doe"') + expect(html).toContain('>@Jane Doe') + expect(html).toContain('data-type="mention"') + }) + + test('preserves ampersands in names without double-escaping the markup', () => { + const users = [{ user_id: 2, name: 'Tom & Jerry' }] + + const html = parseMarkdown('Ping @2', { workspaceUsers: users }) + + expect(html).toContain('Tom & Jerry') + expect(html).not.toContain('Tom & Jerry') + }) +})