Repository: RocketChat/Rocket.Chat
Pull request #32917: fix: External links without target not being modified
Author: login: jeanfbrito
Labels: name: stat: ready to merge
description: PR tested and approved waiting for merge
name: stat: QA assured
description: Means it has been tested and approved by a company insider
Created: 2024-07-26T19:45:41Z | Merged: 2025-05-18T21:25:58Z

<!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. -->

<!-- Your Pull Request name should start with one of the following tags
  feat: Adding a new feature
  refactor: A code change that doesn't change behavior (it doesn't add anything and doesn't fix anything)
  fix: For bug fixes that affect the end-user
  chore: For small tasks
  docs: For documentation
  ci: For updating CI configuration
  test: For adding tests
  i18n: For updating any translations
  regression: Issues created/reported/fixed during the development phase. kind of problem that never existed in production and that we don't need to list in a changelog for the end user
-->

<!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. 
  - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc
  - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat
  - Lint and unit tests pass locally with my changes
  - I have added tests that prove my fix is effective or that my feature works (if applicable)
  - I have added necessary documentation (if applicable)
  - Any dependent changes have been merged and published in downstream modules
-->

## Proposed changes (including videos or screenshots)
<!--
  Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request.
  If it fixes a bug or resolves a feature request, be sure to link to that issue below.
  This description won't be displayed to our end users in the release notes, so feel free to add as much technical context as needed.
  If the changes introduced in this pull request must be presented in the release notes, make sure to add a changeset file. Check our guidelines for adding a changeset to your pull request: https://developer.rocket.chat/contribute-to-rocket.chat/modes-of-contribution/participate-in-rocket.chat-development/development-workflow#4.-adding-changeset-to-your-pull-request 
-->

## Issue(s)
<!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 -->

Closes https://github.com/RocketChat/Rocket.Chat/issues/32911

## Steps to test or reproduce
<!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable -->

## Further comments
<!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... -->
[CORE-580]

---

This pull request addresses an issue in the Rocket.Chat repository where external links in settings were not being modified to open in new tabs. The changes include adding a changeset entry to ensure that these links are correctly set to open in new tabs, enhancing user experience by preventing navigation away from the current page. The changes are made in the `fix/i18n-external-links` branch and are intended to be merged into the `develop` branch.

[CORE-580]: https://rocketchat.atlassian.net/browse/CORE-580?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/.changeset/serious-trees-whisper.md b/.changeset/serious-trees-whisper.md
new file mode 100644
index 0000000000000..aeca4c1d11cc3
--- /dev/null
+++ b/.changeset/serious-trees-whisper.md
@@ -0,0 +1,5 @@
+---
+"@rocket.chat/meteor": patch
+---
+
+Fixed links on settings redirecting to the outside of the chat, now they will open on a new tab
diff --git a/apps/meteor/client/components/MarkdownText.spec.tsx b/apps/meteor/client/components/MarkdownText.spec.tsx
index 08671faec7069..a2aaaeaea1ede 100644
--- a/apps/meteor/client/components/MarkdownText.spec.tsx
+++ b/apps/meteor/client/components/MarkdownText.spec.tsx
@@ -1,10 +1,17 @@
 import { mockAppRoot } from '@rocket.chat/mock-providers';
-import { render } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
 
 import MarkdownText from './MarkdownText';
 
 import '@testing-library/jest-dom';
 
+// Mock getBaseURI from @rocket.chat/ui-client to ensure consistent behavior in tests
+// This will affect the isExternal function imported above.
+jest.mock('@rocket.chat/ui-client', () => ({
+	...jest.requireActual('@rocket.chat/ui-client'), // Import and retain default behavior for other exports
+	getBaseURI: jest.fn(() => 'http://localhost/'), // Mock getBaseURI for consistent test behavior
+}));
+
 const normalizeHtml = (html: any) => {
 	return html.replace(/\s+/g, ' ').trim();
 };
@@ -58,8 +65,17 @@ it('should render html elements as expected using default parser', async () => {
 	expect(normalizedHtml).toContain('<h3>Heading 3</h3>');
 	expect(normalizedHtml).toContain('<ul> <li>List Item 1</li><li>List Item 2</li><li>List Item 3</li><li>List Item 4');
 	expect(normalizedHtml).toContain('<ol> <li>List Item 1</li><li>List Item 2</li><li>List Item 3</li><li>List Item 4');
-	expect(normalizedHtml).toContain('<a title="" rel="nofollow noopener noreferrer" target="_blank">Rocket.Chat</a>');
+
+	expect(normalizedHtml).toContain('<a');
+	expect(normalizedHtml).toContain('title=""');
+	expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
+	expect(normalizedHtml).toContain('target="_blank"');
+	expect(normalizedHtml).toContain('>Rocket.Chat</a>');
+
+	expect(normalizedHtml).toContain('href="mailto:gabriel.engel@rocket.chat"');
+	expect(normalizedHtml).toContain('title="mailto:gabriel.engel@rocket.chat"');
 	expect(normalizedHtml).toContain('gabriel.engel@rocket.chat');
+
 	expect(normalizedHtml).toContain('+55991999999');
 	expect(normalizedHtml).toContain('<code>Inline code</code>');
 	expect(normalizedHtml).toContain('<pre><code class="language-typescript">const test = \'this is code\' </code></pre>');
@@ -87,10 +103,18 @@ it('should render html elements as expected using inline parser', async () => {
 	expect(normalizedHtml).toContain('### Heading 3');
 	expect(normalizedHtml).toContain('<strong>Unordered List</strong> - List Item 1 - List Item 2 - List Item 3 - List Item 4');
 	expect(normalizedHtml).toContain('<strong>Ordered List</strong> 1. List Item 1 2. List Item 2 3. List Item 3 4. List Item 4');
-	expect(normalizedHtml).toContain(`<a title=\"\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">Rocket.Chat</a>`);
-	expect(normalizedHtml).toContain(
-		`<a href=\"mailto:gabriel.engel@rocket.chat\" title=\"mailto:gabriel.engel@rocket.chat\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">gabriel.engel@rocket.chat</a>`,
-	);
+
+	expect(normalizedHtml).toContain('<a');
+	expect(normalizedHtml).toContain('title=""');
+	expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
+	expect(normalizedHtml).toContain('target="_blank"');
+	expect(normalizedHtml).toContain('>Rocket.Chat</a>');
+
+	expect(normalizedHtml).toContain('href="mailto:gabriel.engel@rocket.chat"');
+	expect(normalizedHtml).toContain('title="mailto:gabriel.engel@rocket.chat"');
+	expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
+	expect(normalizedHtml).toContain('target="_blank"');
+
 	expect(normalizedHtml).toContain('+55991999999');
 	expect(normalizedHtml).toContain('Inline code');
 	expect(normalizedHtml).toContain(`typescript const test = 'this is code'`);
@@ -99,3 +123,148 @@ it('should render html elements as expected using inline parser', async () => {
 	expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
 	expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
 });
+
+describe('links handling', () => {
+	it.each([
+		{
+			caseName: 'transform external http',
+			link: 'http://example.com',
+			query: () => screen.getByRole('link', { name: 'Test Link' }),
+			expectedHref: 'http://example.com',
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: '',
+		},
+		{
+			caseName: 'transform external https',
+			link: 'https://example.com',
+			query: () => screen.getByRole('link', { name: 'Test Link' }),
+			expectedHref: 'https://example.com',
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: '',
+		},
+		{
+			caseName: 'transform notes',
+			link: 'notes://example.com/path',
+			query: () => screen.getByRole('link', { name: 'Test Link' }),
+			expectedHref: 'notes://example.com/path',
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: '',
+		},
+		{
+			caseName: 'transform ftp',
+			link: 'ftp://example.com/file.txt',
+			query: () => screen.getByRole('link', { name: 'Test Link' }),
+			expectedHref: 'ftp://example.com/file.txt',
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: '',
+		},
+		{
+			caseName: 'transform ftps',
+			link: 'ftps://example.com/file.txt',
+			query: () => screen.getByRole('link', { name: 'Test Link' }),
+			expectedHref: 'ftps://example.com/file.txt',
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: '',
+		},
+		{
+			caseName: 'transform tel',
+			link: 'tel:+1234567890',
+			query: () => screen.getByRole('link', { name: 'Test Link' }),
+			expectedHref: 'tel:+1234567890',
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: '',
+		},
+		{
+			caseName: 'transform mailto',
+			link: 'mailto:test@example.com',
+			query: () => screen.getByRole('link', { name: 'mailto:test@example.com' }),
+			expectedHref: 'mailto:test@example.com',
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: 'mailto:test@example.com',
+		},
+		{
+			caseName: 'transform sms',
+			link: 'sms:+1234567890?body=hello',
+			query: () => screen.getByRole('link', { name: 'Test Link' }),
+			expectedHref: 'sms:+1234567890?body=hello',
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: '',
+		},
+		{
+			caseName: 'transform cid',
+			link: 'cid:someimage@example.com',
+			query: () => screen.getByRole('link', { name: 'Test Link' }),
+			expectedHref: 'cid:someimage@example.com',
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: '',
+		},
+		{
+			caseName: 'filter relative',
+			link: '/channel/general',
+			query: () => screen.getByText('Test Link'),
+			expectedHref: undefined,
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: '',
+		},
+		{
+			caseName: 'transform absolute',
+			link: 'http://localhost/another-channel',
+			query: () => screen.getByRole('link', { name: 'Go to: another-channel' }),
+			expectedHref: 'http://localhost/another-channel',
+			expectedRel: undefined,
+			expectedTarget: undefined,
+			expectedTitleAttribute: 'Go to: another-channel',
+		},
+		{
+			caseName: 'filter unknown scheme',
+			link: 'invalid://example.com',
+			query: () => screen.getByText('Test Link'),
+			expectedHref: undefined,
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: '',
+		},
+		{
+			caseName: 'filter javascript',
+			link: "javascript:alert('XSS')",
+			query: () => screen.getByText('Test Link'),
+			expectedHref: undefined,
+			expectedRel: 'nofollow noopener noreferrer',
+			expectedTarget: '_blank',
+			expectedTitleAttribute: '',
+		},
+	] as const)('should $caseName links', ({ link, query, expectedHref, expectedRel, expectedTarget, expectedTitleAttribute }) => {
+		const markdownContent = `[Test Link](${link})`;
+		render(<MarkdownText content={markdownContent} variant='document' />, {
+			wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
+		});
+
+		const anchorElement = query();
+
+		expect(anchorElement).toBeInTheDocument();
+		expect(anchorElement.tagName).toBe('A');
+		expect(anchorElement).toHaveTextContent('Test Link');
+
+		if (expectedHref !== undefined) expect(anchorElement).toHaveAttribute('href', expectedHref);
+		else expect(anchorElement).not.toHaveAttribute('href');
+
+		if (expectedRel !== undefined) expect(anchorElement).toHaveAttribute('rel', expectedRel);
+		else expect(anchorElement).not.toHaveAttribute('rel');
+
+		if (expectedTarget !== undefined) expect(anchorElement).toHaveAttribute('target', expectedTarget);
+		else expect(anchorElement).not.toHaveAttribute('target');
+
+		if (expectedTitleAttribute !== undefined) expect(anchorElement).toHaveAttribute('title', expectedTitleAttribute);
+		else expect(anchorElement).not.toHaveAttribute('title');
+	});
+});
diff --git a/apps/meteor/client/components/MarkdownText.tsx b/apps/meteor/client/components/MarkdownText.tsx
index da65fd3b1b48f..acdc85f8a01ff 100644
--- a/apps/meteor/client/components/MarkdownText.tsx
+++ b/apps/meteor/client/components/MarkdownText.tsx
@@ -32,8 +32,9 @@ const walkTokens = (token: marked.Token) => {
 
 marked.use({ walkTokens });
 
-const linkMarked = (href: string | null, _title: string | null, text: string): string =>
-	`<a href="${href}" rel="nofollow noopener noreferrer">${text}</a> `;
+const linkMarked = (href: string | null, _title: string | null, text: string): string => {
+	return `<a href="${href || ''}">${text}</a>`;
+};
 const paragraphMarked = (text: string): string => text;
 const brMarked = (): string => ' ';
 const listItemMarked = (text: string): string => {
@@ -132,20 +133,40 @@ const MarkdownText = ({
 
 		// Add a hook to make all external links open a new window
 		dompurify.addHook('afterSanitizeAttributes', (node) => {
-			if (isElement(node) && 'target' in node) {
-				const href = node.getAttribute('href') || '';
+			if (!isLinkElement(node)) {
+				return;
+			}
 
-				node.setAttribute('title', `${t('Go_to_href', { href: href.replace(getBaseURI(), '') })}`);
+			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');
-				if (isExternal(node.getAttribute('href') || '')) {
-					node.setAttribute('target', '_blank');
-					node.setAttribute('title', href);
-				}
+				// 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(schemes) });
-	}, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t]);
+	}, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t, schemes]);
 
 	return __html ? (
 		<Box
@@ -158,5 +179,6 @@ const MarkdownText = ({
 };
 
 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;
diff --git a/apps/meteor/client/views/admin/settings/Setting/Setting.tsx b/apps/meteor/client/views/admin/settings/Setting/Setting.tsx
index 04f4e8cf061e9..a33ca271e1d70 100644
--- a/apps/meteor/client/views/admin/settings/Setting/Setting.tsx
+++ b/apps/meteor/client/views/admin/settings/Setting/Setting.tsx
@@ -104,10 +104,7 @@ function Setting({ className = undefined, settingId, sectionChanged }: SettingPr
 	const labelText = (i18n.exists(i18nLabel) && t(i18nLabel)) || (i18n.exists(_id) && t(_id)) || i18nLabel || _id;
 
 	const hint = useMemo(
-		() =>
-			i18nDescription && i18n.exists(i18nDescription) ? (
-				<MarkdownText variant='inline' preserveHtml content={t(i18nDescription)} />
-			) : undefined,
+		() => (i18nDescription && i18n.exists(i18nDescription) ? <MarkdownText variant='inline' content={t(i18nDescription)} /> : undefined),
 		[i18n, i18nDescription, t],
 	);
 


Reviews:
user: login: aleksandernsilva
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2024-08-05T21:25:50Z
user: login: tassoevan
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-12T20:33:07Z
user: login: dougfabris
body: LGTM!
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-14T18:56:01Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T00:21:41Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:11:53Z
user: login: tassoevan
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-18T21:25:36Z

Review comments:
diff_hunk: @@ -99,3 +124,107 @@
 	expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
 	expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
 });
+
+describe('MarkdownText scheme handling', () => {
+	// Initial data describing each scheme and its specific properties
+	const schemesData = [
+		{ schemeName: 'http-ext', link: 'http://example.com' },
+		{ schemeName: 'https-ext', link: 'https://example.com' },
+		{ schemeName: 'notes', link: 'notes://example.com/path' },
+		{ schemeName: 'ftp', link: 'ftp://example.com/file.txt' },
+		{ schemeName: 'ftps', link: 'ftps://example.com/file.txt' },
+		{ schemeName: 'tel', link: 'tel:+1234567890' },
+		{ schemeName: 'mailto', link: 'mailto:test@example.com', explicitTitle: 'mailto:test@example.com' },
+		{ schemeName: 'sms', link: 'sms:+1234567890?body=hello' },
+		{ schemeName: 'cid', link: 'cid:someimage@example.com' },
+		{ schemeName: 'internal-relative', link: '/channel/general', isRemoved: true },
+		{ schemeName: 'internal-absolute', link: 'http://localhost/another-channel' },
+		{ schemeName: 'invalid-scheme', link: 'invalid://example.com', isRemoved: true },
+		{ schemeName: 'javascript-uri', link: "javascript:alert('XSS')", isRemoved: true },
+	];
+
+	// Process schemesData to pre-calculate all expected attributes
+	const testCases = schemesData.map(({ schemeName, link, explicitTitle, isRemoved }) => {
+		const isActuallyExternal = isExternal(link); // Uses mocked getBaseURI
+
+		let expectedTitleAttr: string | undefined = explicitTitle;
+		if (expectedTitleAttr === undefined) {
+			if (isRemoved) {
+				expectedTitleAttr = ''; // Removed/neutralized links get an empty title
+			} else {
+				expectedTitleAttr = isActuallyExternal ? '' : 'Go_to_href';
+			}
+		}
+
+		const shouldHaveExternalRelTarget = isRemoved || isActuallyExternal || link.startsWith('mailto:');
+
+		return {
+			schemeName,
+			link,
+			isRemoved: Boolean(isRemoved),
+			isActuallyExternal,
+			expectedHref: link,
+			expectedRel: shouldHaveExternalRelTarget ? 'nofollow noopener noreferrer' : undefined,
+			expectedTarget: shouldHaveExternalRelTarget ? '_blank' : undefined,
+			expectedTitleAttribute: expectedTitleAttr,
+		};
+	});
+
+	testCases.forEach((tc) => {
+		it(`should ${tc.isRemoved ? 'handle' : 'correctly process'} ${tc.schemeName} links (isExternal returned: ${
+			tc.isActuallyExternal
+		})`, () => {
+			const markdownContent = `[Test Link](${tc.link})`;
+			const { container } = render(<MarkdownText content={markdownContent} variant='document' />, {
+				wrapper: mockAppRoot().build(),
+			});
+
+			const anchor = container.querySelector('a');
+			expect(anchor).not.toBeNull();
+			if (!anchor) return; // Type guard
+
+			// Assert href
+			if (tc.isRemoved) {
+				const hrefAttr = anchor.getAttribute('href');
+				if (tc.link.startsWith('javascript:')) {
path: apps/meteor/client/components/MarkdownText.spec.tsx
user: login: github-advanced-security[bot]
body: ## Incomplete URL scheme check

This check does not consider data: and vbscript:.

[Show more details](https://github.com/RocketChat/Rocket.Chat/security/code-scanning/477)
created_at: 2025-05-15T00:21:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,3 +124,107 @@
 	expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
 	expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
 });
+
+describe('MarkdownText scheme handling', () => {
+	// Initial data describing each scheme and its specific properties
+	const schemesData = [
+		{ schemeName: 'http-ext', link: 'http://example.com' },
+		{ schemeName: 'https-ext', link: 'https://example.com' },
+		{ schemeName: 'notes', link: 'notes://example.com/path' },
+		{ schemeName: 'ftp', link: 'ftp://example.com/file.txt' },
+		{ schemeName: 'ftps', link: 'ftps://example.com/file.txt' },
+		{ schemeName: 'tel', link: 'tel:+1234567890' },
+		{ schemeName: 'mailto', link: 'mailto:test@example.com', explicitTitle: 'mailto:test@example.com' },
+		{ schemeName: 'sms', link: 'sms:+1234567890?body=hello' },
+		{ schemeName: 'cid', link: 'cid:someimage@example.com' },
+		{ schemeName: 'internal-relative', link: '/channel/general', isRemoved: true },
+		{ schemeName: 'internal-absolute', link: 'http://localhost/another-channel' },
+		{ schemeName: 'invalid-scheme', link: 'invalid://example.com', isRemoved: true },
+		{ schemeName: 'javascript-uri', link: "javascript:alert('XSS')", isRemoved: true },
+	];
+
+	// Process schemesData to pre-calculate all expected attributes
+	const testCases = schemesData.map(({ schemeName, link, explicitTitle, isRemoved }) => {
+		const isActuallyExternal = isExternal(link); // Uses mocked getBaseURI
+
+		let expectedTitleAttr: string | undefined = explicitTitle;
+		if (expectedTitleAttr === undefined) {
+			if (isRemoved) {
+				expectedTitleAttr = ''; // Removed/neutralized links get an empty title
+			} else {
+				expectedTitleAttr = isActuallyExternal ? '' : 'Go_to_href';
+			}
+		}
+
+		const shouldHaveExternalRelTarget = isRemoved || isActuallyExternal || link.startsWith('mailto:');
+
+		return {
+			schemeName,
+			link,
+			isRemoved: Boolean(isRemoved),
+			isActuallyExternal,
+			expectedHref: link,
+			expectedRel: shouldHaveExternalRelTarget ? 'nofollow noopener noreferrer' : undefined,
+			expectedTarget: shouldHaveExternalRelTarget ? '_blank' : undefined,
+			expectedTitleAttribute: expectedTitleAttr,
+		};
+	});
+
+	testCases.forEach((tc) => {
+		it(`should ${tc.isRemoved ? 'handle' : 'correctly process'} ${tc.schemeName} links (isExternal returned: ${
+			tc.isActuallyExternal
+		})`, () => {
+			const markdownContent = `[Test Link](${tc.link})`;
+			const { container } = render(<MarkdownText content={markdownContent} variant='document' />, {
+				wrapper: mockAppRoot().build(),
+			});
+
+			const anchor = container.querySelector('a');
+			expect(anchor).not.toBeNull();
+			if (!anchor) return; // Type guard
+
+			// Assert href
+			if (tc.isRemoved) {
+				const hrefAttr = anchor.getAttribute('href');
+				if (tc.link.startsWith('javascript:')) {
+					expect(hrefAttr === null || hrefAttr === '#' || !hrefAttr.startsWith('javascript:')).toBe(true);
path: apps/meteor/client/components/MarkdownText.spec.tsx
user: login: github-advanced-security[bot]
body: ## Incomplete URL scheme check

This check does not consider data: and vbscript:.

[Show more details](https://github.com/RocketChat/Rocket.Chat/security/code-scanning/478)
created_at: 2025-05-15T00:21:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,3 +124,137 @@
 	expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
 	expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
 });
+
+describe('MarkdownText scheme handling', () => {
+	const schemesData = [
+		{ schemeName: 'http-ext', link: 'http://example.com' },
+		{ schemeName: 'https-ext', link: 'https://example.com' },
+		{ schemeName: 'notes', link: 'notes://example.com/path' },
+		{ schemeName: 'ftp', link: 'ftp://example.com/file.txt' },
+		{ schemeName: 'ftps', link: 'ftps://example.com/file.txt' },
+		{ schemeName: 'tel', link: 'tel:+1234567890' },
+		{ schemeName: 'mailto', link: 'mailto:test@example.com', explicitTitle: 'mailto:test@example.com' },
+		{ schemeName: 'sms', link: 'sms:+1234567890?body=hello' },
+		{ schemeName: 'cid', link: 'cid:someimage@example.com' },
+		{ schemeName: 'internal-relative', link: '/channel/general', isRemoved: true },
+		{ schemeName: 'internal-absolute', link: 'http://localhost/another-channel' },
+		{ schemeName: 'invalid-scheme', link: 'invalid://example.com', isRemoved: true },
+		{ schemeName: 'javascript-uri', link: "javascript:alert('XSS')", isRemoved: true },
+	];
+
+	const testCases = schemesData.map(({ schemeName, link, explicitTitle, isRemoved }) => {
+		const isActuallyExternal = isExternal(link);
+
+		let expectedTitleAttr: string | undefined = explicitTitle;
+		if (expectedTitleAttr === undefined) {
+			if (isRemoved) {
+				expectedTitleAttr = '';
+			} else if (isActuallyExternal) {
+				expectedTitleAttr = '';
+			} else {
+				expectedTitleAttr = 'Go_to_href';
+			}
+		}
+
+		const shouldHaveExternalRelTarget = isRemoved || isActuallyExternal || link.startsWith('mailto:');
+
+		let queryStrategy: { method: 'getByRole'; name: string | RegExp } | { method: 'getByText'; text: string | RegExp };
+
+		if (expectedTitleAttr && expectedTitleAttr !== '') {
+			queryStrategy = { method: 'getByRole', name: expectedTitleAttr };
+		} else if (link.startsWith('mailto:') && !expectedTitleAttr) {
+			queryStrategy = { method: 'getByRole', name: link };
+		} else {
+			// For links with empty titles or no specific title attribute, target by text content.
+			queryStrategy = { method: 'getByText', text: 'Test Link' };
+		}
+
+		return {
+			schemeName,
+			link,
+			isRemoved: Boolean(isRemoved),
+			isActuallyExternal,
+			expectedHref: link,
+			expectedRel: shouldHaveExternalRelTarget ? 'nofollow noopener noreferrer' : undefined,
+			expectedTarget: shouldHaveExternalRelTarget ? '_blank' : undefined,
+			expectedTitleAttribute: expectedTitleAttr,
+			queryStrategy,
+		};
+	});
+
+	testCases.forEach((tc) => {
+		it(`should ${tc.isRemoved ? 'handle' : 'correctly process'} ${tc.schemeName} links (isExternal returned: ${tc.isActuallyExternal})`, () => {
+			const markdownContent = `[Test Link](${tc.link})`;
+			render(<MarkdownText content={markdownContent} variant='document' />, {
+				wrapper: mockAppRoot().build(),
+			});
+
+			let anchorElement: HTMLElement;
+			if (tc.queryStrategy.method === 'getByRole') {
+				anchorElement = screen.getByRole('link', { name: tc.queryStrategy.name });
+			} else {
+				// tc.queryStrategy.method === 'getByText'
+				const elementWithText = screen.getByText(tc.queryStrategy.text);
+				if (elementWithText.tagName === 'A') {
+					anchorElement = elementWithText;
+				} else {
+					const parentAnchor = elementWithText.closest('a');
+					if (!parentAnchor) {
+						// This case should ideally not happen if markdown `[Text](link)` is parsed correctly to an anchor.
+						// If it does, it indicates a deeper issue or a test case where text isn't in an anchor.
+						throw new Error(`Query by text found "${tc.queryStrategy.text}", but it is not within an anchor tag.`);
+					}
+					anchorElement = parentAnchor;
+				}
+			}
+
+			expect(anchorElement).toBeInTheDocument();
+			expect(anchorElement.tagName).toBe('A');
+
+			// Assert href
+			if (tc.isRemoved) {
+				// eslint-disable-next-line testing-library/no-node-access -- Need to check href value against multiple possibilities for sanitized links
+				const currentHref = anchorElement.getAttribute('href');
+				if (tc.link.startsWith('javascript:')) {
path: apps/meteor/client/components/MarkdownText.spec.tsx
user: login: github-advanced-security[bot]
body: ## Incomplete URL scheme check

This check does not consider data: and vbscript:.

[Show more details](https://github.com/RocketChat/Rocket.Chat/security/code-scanning/479)
created_at: 2025-05-15T15:11:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,3 +124,137 @@
 	expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
 	expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
 });
+
+describe('MarkdownText scheme handling', () => {
+	const schemesData = [
+		{ schemeName: 'http-ext', link: 'http://example.com' },
+		{ schemeName: 'https-ext', link: 'https://example.com' },
+		{ schemeName: 'notes', link: 'notes://example.com/path' },
+		{ schemeName: 'ftp', link: 'ftp://example.com/file.txt' },
+		{ schemeName: 'ftps', link: 'ftps://example.com/file.txt' },
+		{ schemeName: 'tel', link: 'tel:+1234567890' },
+		{ schemeName: 'mailto', link: 'mailto:test@example.com', explicitTitle: 'mailto:test@example.com' },
+		{ schemeName: 'sms', link: 'sms:+1234567890?body=hello' },
+		{ schemeName: 'cid', link: 'cid:someimage@example.com' },
+		{ schemeName: 'internal-relative', link: '/channel/general', isRemoved: true },
+		{ schemeName: 'internal-absolute', link: 'http://localhost/another-channel' },
+		{ schemeName: 'invalid-scheme', link: 'invalid://example.com', isRemoved: true },
+		{ schemeName: 'javascript-uri', link: "javascript:alert('XSS')", isRemoved: true },
+	];
+
+	const testCases = schemesData.map(({ schemeName, link, explicitTitle, isRemoved }) => {
+		const isActuallyExternal = isExternal(link);
+
+		let expectedTitleAttr: string | undefined = explicitTitle;
+		if (expectedTitleAttr === undefined) {
+			if (isRemoved) {
+				expectedTitleAttr = '';
+			} else if (isActuallyExternal) {
+				expectedTitleAttr = '';
+			} else {
+				expectedTitleAttr = 'Go_to_href';
+			}
+		}
+
+		const shouldHaveExternalRelTarget = isRemoved || isActuallyExternal || link.startsWith('mailto:');
+
+		let queryStrategy: { method: 'getByRole'; name: string | RegExp } | { method: 'getByText'; text: string | RegExp };
+
+		if (expectedTitleAttr && expectedTitleAttr !== '') {
+			queryStrategy = { method: 'getByRole', name: expectedTitleAttr };
+		} else if (link.startsWith('mailto:') && !expectedTitleAttr) {
+			queryStrategy = { method: 'getByRole', name: link };
+		} else {
+			// For links with empty titles or no specific title attribute, target by text content.
+			queryStrategy = { method: 'getByText', text: 'Test Link' };
+		}
+
+		return {
+			schemeName,
+			link,
+			isRemoved: Boolean(isRemoved),
+			isActuallyExternal,
+			expectedHref: link,
+			expectedRel: shouldHaveExternalRelTarget ? 'nofollow noopener noreferrer' : undefined,
+			expectedTarget: shouldHaveExternalRelTarget ? '_blank' : undefined,
+			expectedTitleAttribute: expectedTitleAttr,
+			queryStrategy,
+		};
+	});
+
+	testCases.forEach((tc) => {
+		it(`should ${tc.isRemoved ? 'handle' : 'correctly process'} ${tc.schemeName} links (isExternal returned: ${tc.isActuallyExternal})`, () => {
+			const markdownContent = `[Test Link](${tc.link})`;
+			render(<MarkdownText content={markdownContent} variant='document' />, {
+				wrapper: mockAppRoot().build(),
+			});
+
+			let anchorElement: HTMLElement;
+			if (tc.queryStrategy.method === 'getByRole') {
+				anchorElement = screen.getByRole('link', { name: tc.queryStrategy.name });
+			} else {
+				// tc.queryStrategy.method === 'getByText'
+				const elementWithText = screen.getByText(tc.queryStrategy.text);
+				if (elementWithText.tagName === 'A') {
+					anchorElement = elementWithText;
+				} else {
+					const parentAnchor = elementWithText.closest('a');
+					if (!parentAnchor) {
+						// This case should ideally not happen if markdown `[Text](link)` is parsed correctly to an anchor.
+						// If it does, it indicates a deeper issue or a test case where text isn't in an anchor.
+						throw new Error(`Query by text found "${tc.queryStrategy.text}", but it is not within an anchor tag.`);
+					}
+					anchorElement = parentAnchor;
+				}
+			}
+
+			expect(anchorElement).toBeInTheDocument();
+			expect(anchorElement.tagName).toBe('A');
+
+			// Assert href
+			if (tc.isRemoved) {
+				// eslint-disable-next-line testing-library/no-node-access -- Need to check href value against multiple possibilities for sanitized links
+				const currentHref = anchorElement.getAttribute('href');
+				if (tc.link.startsWith('javascript:')) {
+					// For JS links, ensure href is neutralized (null, '#', or non-JS)
+					expect(
+						currentHref === null || // Stripped completely
+							currentHref === '#' || // Commonly neutralized to #
+							!currentHref.startsWith('javascript:'), // Original but non-functional due to other attributes or browser measures
path: apps/meteor/client/components/MarkdownText.spec.tsx
user: login: github-advanced-security[bot]
body: ## Incomplete URL scheme check

This check does not consider data: and vbscript:.

[Show more details](https://github.com/RocketChat/Rocket.Chat/security/code-scanning/480)
created_at: 2025-05-15T15:11:53Z
author_association: CONTRIBUTOR

PR comments:
user: login: dionisio-bot[bot]
created_at: 2024-07-26T19:45:45Z
body: Looks like this PR is ready to merge! 🎉
If you have any trouble, please check the [PR guidelines](https://handbook.rocket.chat/departments-and-operations/research-and-development/engineering/development/pr-general-instructions-and-handling)
author_association: CONTRIBUTOR
user: login: changeset-bot[bot]
created_at: 2024-07-26T19:45:45Z
body: ###  🦋  Changeset detected

Latest commit: 6658c2eef258e6512226e273c7b56bcdaf77b655

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 36 packages</summary>

  | Name                                | Type  |
| ----------------------------------- | ----- |
| @rocket.chat/meteor                 | Patch |
| @rocket.chat/core-typings           | Patch |
| @rocket.chat/rest-typings           | Patch |
| @rocket.chat/uikit-playground       | Patch |
| @rocket.chat/api-client             | Patch |
| @rocket.chat/apps                   | Patch |
| @rocket.chat/core-services          | Patch |
| @rocket.chat/cron                   | Patch |
| @rocket.chat/ddp-client             | Patch |
| @rocket.chat/freeswitch             | Patch |
| @rocket.chat/fuselage-ui-kit        | Patch |
| @rocket.chat/gazzodown              | Patch |
| @rocket.chat/livechat               | Patch |
| @rocket.chat/model-typings          | Patch |
| @rocket.chat/ui-contexts            | Patch |
| @rocket.chat/account-service        | Patch |
| @rocket.chat/authorization-service  | Patch |
| @rocket.chat/ddp-streamer           | Patch |
| @rocket.chat/omnichannel-transcript | Patch |
| @rocket.chat/presence-service       | Patch |
| @rocket.chat/queue-worker           | Patch |
| @rocket.chat/stream-hub-service     | Patch |
| @rocket.chat/license                | Patch |
| @rocket.chat/omnichannel-services   | Patch |
| @rocket.chat/pdf-worker             | Patch |
| @rocket.chat/presence               | Patch |
| rocketchat-services                 | Patch |
| @rocket.chat/models                 | Patch |
| @rocket.chat/network-broker         | Patch |
| @rocket.chat/mock-providers         | Patch |
| @rocket.chat/ui-avatar              | Patch |
| @rocket.chat/ui-client              | Patch |
| @rocket.chat/ui-video-conf          | Patch |
| @rocket.chat/ui-voip                | Patch |
| @rocket.chat/web-ui-registration    | Patch |
| @rocket.chat/instance-status        | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/RocketChat/Rocket.Chat/new/fix/i18n-external-links?filename=.changeset/slimy-snakes-remember.md&value=---%0A%22%40rocket.chat%2Fmeteor%22%3A%20patch%0A---%0A%0Afix%3A%20External%20links%20without%20target%20not%20being%20modified%0A)


author_association: NONE
user: login: codecov[bot]
created_at: 2024-07-26T19:53:34Z
body: ## [Codecov](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/32917?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 64.87%. Comparing base [(`b6b11f3`)](https://app.codecov.io/gh/RocketChat/Rocket.Chat/commit/b6b11f3115578ecdfd513dfe94af56562a00ed6d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat) to head [(`6658c2e`)](https://app.codecov.io/gh/RocketChat/Rocket.Chat/commit/6658c2eef258e6512226e273c7b56bcdaf77b655?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat).
> Report is 1 commits behind head on develop.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/32917/graphs/tree.svg?width=650&height=150&src=pr&token=lEAH159Fca&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat)](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/32917?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat)

```diff
@@             Coverage Diff             @@
##           develop   #32917      +/-   ##
===========================================
+ Coverage    64.84%   64.87%   +0.02%     
===========================================
  Files         3099     3099              
  Lines        92324    92346      +22     
  Branches     17629    17636       +7     
===========================================
+ Hits         59871    59905      +34     
+ Misses       29685    29679       -6     
+ Partials      2768     2762       -6     
```

| [Flag](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/32917/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat) | Coverage Δ | |
|---|---|---|
| [e2e](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/32917/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat) | `58.40% <56.25%> (-0.02%)` | :arrow_down: |
| [unit](https://app.codecov.io/gh/RocketChat/Rocket.Chat/pull/32917/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat) | `72.00% <100.00%> (+0.03%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RocketChat#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: kody-ai[bot]
created_at: 2025-05-09T12:20:10Z
body: # Kody Review Complete
**Great news!** 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

<details>
<summary>Kody Guide: Usage and Configuration</summary>

<details>
<summary>Interacting with Kody</summary>

- **Request a Review:** Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

- **Provide Feedback:** Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

</details>

<details>
<summary>Current Kody Configuration</summary>

<details>
<summary>Review Options</summary>

The following review options are enabled or disabled:

| Options                        | Enabled |
|-------------------------------|---------|
| **Security** | ✅ |
| **Code Style** | ✅ |
| **Kody Rules** | ✅ |
| **Refactoring** | ✅ |
| **Error Handling** | ✅ |
| **Maintainability** | ✅ |
| **Potential Issues** | ✅ |
| **Documentation And Comments** | ✅ |
| **Performance And Optimization** | ✅ |
| **Breaking Changes** | ❌ |

</details>

**[Access your configuration settings here.](https://app.kodus.io/settings/code-review/global/general)**

</details>
</details>

<!-- kody-codereview -->
&#8203;
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-09T12:29:58Z
body: [PR Preview Action](https://github.com/rossjrw/pr-preview-action) v1.6.1
:---:
| <p></p> :rocket: View preview at <br> https://RocketChat.github.io/Rocket.Chat/pr-preview/pr-32917/ <br><br>
| <h6>Built to branch [`gh-pages`](https://github.com/RocketChat/Rocket.Chat/tree/gh-pages) at 2025-05-18 04:25 UTC. <br> Preview will be ready when the [GitHub Pages deployment](https://github.com/RocketChat/Rocket.Chat/deployments) is complete. <br><br> </h6>
<!-- Sticky Pull Request Commentpr-preview -->
author_association: CONTRIBUTOR