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')
+ })
+})