diff --git a/apps/meteor/client/components/MarkdownText.spec.tsx b/apps/meteor/client/components/MarkdownText.spec.tsx
index a7b9da94b84e3..1ed832ee02a45 100644
--- a/apps/meteor/client/components/MarkdownText.spec.tsx
+++ b/apps/meteor/client/components/MarkdownText.spec.tsx
@@ -1,5 +1,6 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
+import dompurify from 'dompurify';
import MarkdownText, { supportedURISchemes } from './MarkdownText';
@@ -432,3 +433,37 @@ describe('code handling', () => {
expect(screen.getByRole('code').outerHTML).toEqual(expected);
});
});
+
+describe('DOMPurify hook registration', () => {
+ it('should register hook only once at module level', () => {
+ // Import the module to trigger hook registration
+
+ const addHookSpy = jest.spyOn(dompurify, 'addHook');
+
+ // Clear any previous calls from module initialization
+ addHookSpy.mockClear();
+
+ const { rerender, unmount } = render(, {
+ wrapper: mockAppRoot().build(),
+ });
+
+ // Hook should NOT be registered during component render (it's registered at module level)
+ expect(addHookSpy).toHaveBeenCalledTimes(0);
+
+ // Re-rendering with different props should not register hook again
+ rerender();
+ expect(addHookSpy).toHaveBeenCalledTimes(0);
+
+ // Rendering another instance should not register hook again
+ render(, {
+ wrapper: mockAppRoot().build(),
+ });
+ expect(addHookSpy).toHaveBeenCalledTimes(0);
+
+ // Unmounting should not affect the module-level hook
+ unmount();
+ expect(addHookSpy).toHaveBeenCalledTimes(0);
+
+ addHookSpy.mockRestore();
+ });
+});
diff --git a/apps/meteor/client/components/MarkdownText.tsx b/apps/meteor/client/components/MarkdownText.tsx
index 8503d5ee7ed5f..c490ae5e77b48 100644
--- a/apps/meteor/client/components/MarkdownText.tsx
+++ b/apps/meteor/client/components/MarkdownText.tsx
@@ -101,6 +101,49 @@ type MarkdownTextProps = Partial;
export const supportedURISchemes = ['http', 'https', 'notes', 'ftp', 'ftps', 'tel', 'mailto', 'sms', 'cid'];
+const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE;
+const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a';
+
+// Generate a unique token at runtime to prevent enumeration attacks
+// This token marks internal links that need translation
+const INTERNAL_LINK_TOKEN = `__INTERNAL_LINK_TITLE_${Math.random().toString(36).substring(2, 15)}__`;
+
+// Register the DOMPurify hook once at module level to prevent memory leaks
+// This hook will be shared by all MarkdownText component instances
+dompurify.addHook('afterSanitizeAttributes', (node) => {
+ if (!isLinkElement(node)) {
+ return;
+ }
+
+ const href = node.getAttribute('href') || '';
+ const isExternalLink = isExternal(href);
+ const isMailto = href.startsWith('mailto:');
+
+ // Set appropriate attributes based on link type
+ if (isExternalLink || isMailto) {
+ node.setAttribute('rel', 'nofollow noopener noreferrer');
+ // Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat
+ // This attribute must be preserved to guarantee users maintain their chat context
+ node.setAttribute('target', '_blank');
+ }
+
+ // Set appropriate title based on link type
+ if (isMailto) {
+ // For mailto links, use the email address as the title for better user experience
+ // Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com"
+ node.setAttribute('title', href);
+ } else if (isExternalLink) {
+ // For external links, set an empty title to prevent tooltips
+ // This reduces visual clutter and lets users see the URL in the browser's status bar instead
+ node.setAttribute('title', '');
+ } else {
+ // For internal links, use a token that will be replaced with translated text in the component
+ // This allows us to use the contextualized translation function
+ const relativePath = href.replace(getBaseURI(), '');
+ node.setAttribute('title', `${INTERNAL_LINK_TOKEN}${relativePath}`);
+ }
+});
+
const MarkdownText = ({
content,
variant = 'document',
@@ -143,41 +186,16 @@ const MarkdownText = ({
}
})();
- // Add a hook to make all external links open a new window
- dompurify.addHook('afterSanitizeAttributes', (node) => {
- if (!isLinkElement(node)) {
- return;
- }
+ const sanitizedHtml = preserveHtml
+ ? html
+ : html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) });
- const href = node.getAttribute('href') || '';
- const isExternalLink = isExternal(href);
- const isMailto = href.startsWith('mailto:');
+ // Replace internal link tokens with contextualized translations
+ if (sanitizedHtml && typeof sanitizedHtml === 'string') {
+ return sanitizedHtml.replace(new RegExp(`${INTERNAL_LINK_TOKEN}([^"]*)`, 'g'), (_, href) => t('Go_to_href', { href }));
+ }
- // Set appropriate attributes based on link type
- if (isExternalLink || isMailto) {
- node.setAttribute('rel', 'nofollow noopener noreferrer');
- // Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat
- // This attribute must be preserved to guarantee users maintain their chat context
- node.setAttribute('target', '_blank');
- }
-
- // Set appropriate title based on link type
- if (isMailto) {
- // For mailto links, use the email address as the title for better user experience
- // Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com"
- node.setAttribute('title', href);
- } else if (isExternalLink) {
- // For external links, set an empty title to prevent tooltips
- // This reduces visual clutter and lets users see the URL in the browser's status bar instead
- node.setAttribute('title', '');
- } else {
- // For internal links, add a translated title with the relative path
- // Example: for href "https://my-server.rocket.chat/channel/general" the title would be "Go to #general"
- node.setAttribute('title', `${t('Go_to_href', { href: href.replace(getBaseURI(), '') })}`);
- }
- });
-
- return preserveHtml ? html : html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) });
+ return sanitizedHtml;
}, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t]);
return __html ? (
@@ -190,7 +208,4 @@ const MarkdownText = ({
) : null;
};
-const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE;
-const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a';
-
export default MarkdownText;