From cffd8cfd70539720b70c0fddd03717232942433b Mon Sep 17 00:00:00 2001
From: Will Hunt <2072976+Half-Shot@users.noreply.github.com>
Date: Tue, 7 Apr 2026 08:53:06 +0100
Subject: [PATCH] Disallow links without protocol (e.g. starting with
http(s)://) in LinkedText. (#32972)
* Disallow links without protocols in LinkedText.
* Update tests
---
apps/web/playwright/e2e/links/messages.spec.ts | 9 ++++++++-
apps/web/playwright/e2e/links/topic.spec.ts | 8 +-------
.../src/core/utils/LinkedText/LinkedText.test.tsx | 11 ++++++++++-
packages/shared-components/src/core/utils/linkify.ts | 4 ++--
4 files changed, 21 insertions(+), 11 deletions(-)
diff --git a/apps/web/playwright/e2e/links/messages.spec.ts b/apps/web/playwright/e2e/links/messages.spec.ts
index 1f86e1bc09..74af6c834e 100644
--- a/apps/web/playwright/e2e/links/messages.spec.ts
+++ b/apps/web/playwright/e2e/links/messages.spec.ts
@@ -14,7 +14,7 @@ test.describe("Message links", () => {
await use({ roomId });
},
});
- for (const link of ["https://example.org", "example.org", "ftp://example.org"]) {
+ for (const link of ["https://example.org", "ftp://example.org"]) {
test(`should linkify a regular link '${link}'`, async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
// Needs to be unformatted so we test linkifing
@@ -24,6 +24,13 @@ test.describe("Message links", () => {
await expect(linkElement).toBeVisible();
});
}
+ test("should NOT linkify a bare domain", async ({ page, user, app, room }) => {
+ await page.goto(`#/room/${room.roomId}`);
+ // Needs to be unformatted so we test linkifing
+ await app.client.sendMessage(room.roomId, `Check out example.org`);
+ const linkElement = page.locator(".mx_EventTile_last").getByRole("link", { name: "example.org" });
+ await expect(linkElement).not.toBeVisible();
+ });
test("should linkify a User ID", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
// Needs to be unformatted so we test linkifing
diff --git a/apps/web/playwright/e2e/links/topic.spec.ts b/apps/web/playwright/e2e/links/topic.spec.ts
index f6574860cd..fff866119d 100644
--- a/apps/web/playwright/e2e/links/topic.spec.ts
+++ b/apps/web/playwright/e2e/links/topic.spec.ts
@@ -14,13 +14,7 @@ test.describe("Topic links", () => {
await use({ roomId });
},
});
- for (const link of [
- "https://example.org",
- "example.org",
- "ftp://example.org",
- "#aroom:example.org",
- "@alice:example.org",
- ]) {
+ for (const link of ["https://example.org", "ftp://example.org", "#aroom:example.org", "@alice:example.org"]) {
// Playwright treats '@' as a tag, so replace it to be safe
test(`should linkify plaintext '${link.replace("@", "_@")}'`, async ({ page, user, app, room }) => {
await app.client.sendStateEvent(
diff --git a/packages/shared-components/src/core/utils/LinkedText/LinkedText.test.tsx b/packages/shared-components/src/core/utils/LinkedText/LinkedText.test.tsx
index 850bcb0c15..fb6c8e26f4 100644
--- a/packages/shared-components/src/core/utils/LinkedText/LinkedText.test.tsx
+++ b/packages/shared-components/src/core/utils/LinkedText/LinkedText.test.tsx
@@ -49,6 +49,15 @@ describe("LinkedText", () => {
expect(container).toMatchSnapshot();
});
+ it("does not linkify domains without a protocol.", () => {
+ const { queryAllByRole } = render(
+
+ Check out this link github.com
+ ,
+ );
+ expect(queryAllByRole("link")).toHaveLength(0);
+ });
+
it("renders a user ID", () => {
const { container } = render();
expect(container).toMatchSnapshot();
@@ -73,7 +82,7 @@ describe("LinkedText", () => {
const fn = vitest.fn();
const { getAllByRole } = render(
- Check out this link https://google.com and example.org
+ Check out this link https://google.com and https://example.org
,
);
const links = getAllByRole("link");
diff --git a/packages/shared-components/src/core/utils/linkify.ts b/packages/shared-components/src/core/utils/linkify.ts
index 7ff737c2cb..c4d5caeea9 100644
--- a/packages/shared-components/src/core/utils/linkify.ts
+++ b/packages/shared-components/src/core/utils/linkify.ts
@@ -228,10 +228,10 @@ export function generateLinkedTextOptions({
: undefined),
// By default, ignore Matrix ID types.
// Other applications may implement their own version of LinkifyComponent.
- validate: (_value, type: string) =>
+ validate: (value, type: string) =>
!!(type === LinkifyMatrixOpaqueIdType.UserId && userIdListener) ||
!!(type === LinkifyMatrixOpaqueIdType.RoomAlias && roomAliasListener) ||
- type === LinkifyMatrixOpaqueIdType.URL,
+ !!(type === LinkifyMatrixOpaqueIdType.URL && URL.canParse(value)),
} satisfies linkifyjs.Opts;
}