From 39f2d9ca985cebc2842f5aa576c2be38ac83c295 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste PENRATH Date: Thu, 9 Jul 2026 14:12:09 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20detect=20text=20link=20in?= =?UTF-8?q?=20html=20body=20and=20warn=20user=20before=20redirect=20(#744)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse sanitized html before rendering to detect links that are just raw text then transform them into anchor. Furthermore, for security purpore, we catch event when a user clicks on a link then display a confirmation modal displaying the real link. Co-authored-by: Valentin Regnault --- docs/env.md | 1 + src/backend/core/api/openapi.json | 9 ++ src/backend/core/api/viewsets/config.py | 11 ++ src/backend/core/tests/api/test_config.py | 10 ++ .../e2e/management/commands/e2e_demo.py | 33 ++++- src/backend/messages/settings.py | 12 ++ src/e2e/src/__tests__/link-preview.spec.ts | 122 +++++++++++++++++ src/frontend/public/locales/common/en-US.json | 4 + src/frontend/public/locales/common/fr-FR.json | 4 + .../api/gen/models/config_retrieve200.ts | 2 + .../blocknote/email-exporter/index.test.tsx | 8 -- .../blocknote/email-exporter/index.tsx | 4 +- src/frontend/src/features/config/resolve.ts | 1 + .../components/thread-message/_index.scss | 20 +++ .../thread-message/is-host-trusted.test.ts | 48 +++++++ .../thread-message/is-host-trusted.ts | 30 ++++ .../thread-message/renderers/linkify.test.ts | 74 ++++++++++ .../thread-message/renderers/linkify.ts | 129 ++++++++++++++++++ .../thread-message/thread-message-body.tsx | 109 +++++++++++++-- .../thread-message/use-link-confirmation.tsx | 38 ++++++ .../features/ui/components/banner/_index.scss | 9 +- 21 files changed, 652 insertions(+), 26 deletions(-) create mode 100644 src/e2e/src/__tests__/link-preview.spec.ts create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/thread-message/is-host-trusted.test.ts create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/thread-message/is-host-trusted.ts create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/thread-message/renderers/linkify.test.ts create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/thread-message/renderers/linkify.ts create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/thread-message/use-link-confirmation.tsx diff --git a/docs/env.md b/docs/env.md index f9f4743e..65e582fa 100644 --- a/docs/env.md +++ b/docs/env.md @@ -419,6 +419,7 @@ it can lead to memory exhaustion, increase at your own risk. | `IMAGE_PROXY_ENABLED` | `False` | Whether external images should be proxied | Optional | | `IMAGE_PROXY_MAX_SIZE` | `5242880` (5MB) | Maximum size in bytes for external images | Optional | | `IMAGE_PROXY_CACHE_TTL` | `2592000` (30 days) | Cache TTL in seconds for external images | Optional | +| `MESSAGE_TRUSTED_LINK_DOMAINS` | `[]` | Comma-separated list of hostnames whose external links open without the redirect confirmation modal. A lone `*` trusts every host (disables the modal). A leading `*.` wildcard also matches subdomains (`*.gouv.fr` matches `gouv.fr` and `impots.gouv.fr`); any other entry matches the host exactly (case-insensitive). Masked links (display text pointing to a different host than the real target) always prompt, even when their target is listed here. | Optional | ### Frontend diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index d377447c..869ee882 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -299,6 +299,14 @@ "description": "Whether external images should be proxied", "readOnly": true }, + "MESSAGE_TRUSTED_LINK_DOMAINS": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Hostnames whose external links skip the redirect confirmation modal (a leading *. wildcard also matches subdomains)", + "readOnly": true + }, "FEATURE_MAILDOMAIN_CREATE": { "type": "boolean", "readOnly": true @@ -398,6 +406,7 @@ "MAX_RECIPIENTS_PER_MESSAGE", "MAX_TEMPLATE_IMAGE_SIZE", "IMAGE_PROXY_ENABLED", + "MESSAGE_TRUSTED_LINK_DOMAINS", "FEATURE_MAILDOMAIN_CREATE", "FEATURE_MAILDOMAIN_MANAGE_ACCESSES", "FEATURE_THREAD_SPLIT", diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index f73b5378..465315ba 100644 --- a/src/backend/core/api/viewsets/config.py +++ b/src/backend/core/api/viewsets/config.py @@ -121,6 +121,17 @@ CONFIG_ENTRIES = ( "description": "Whether external images should be proxied", }, ), + ConfigEntry( + "MESSAGE_TRUSTED_LINK_DOMAINS", + { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Hostnames whose external links skip the redirect confirmation " + "modal (a leading *. wildcard also matches subdomains)" + ), + }, + ), ConfigEntry("FEATURE_MAILDOMAIN_CREATE", {"type": "boolean"}), ConfigEntry("FEATURE_MAILDOMAIN_MANAGE_ACCESSES", {"type": "boolean"}), ConfigEntry("FEATURE_THREAD_SPLIT", {"type": "boolean"}), diff --git a/src/backend/core/tests/api/test_config.py b/src/backend/core/tests/api/test_config.py index f5ef05bd..176cee59 100644 --- a/src/backend/core/tests/api/test_config.py +++ b/src/backend/core/tests/api/test_config.py @@ -34,6 +34,7 @@ pytestmark = pytest.mark.django_db MAX_RECIPIENTS_PER_MESSAGE=42, MAX_TEMPLATE_IMAGE_SIZE=2097152, # 2MB IMAGE_PROXY_ENABLED=False, + MESSAGE_TRUSTED_LINK_DOMAINS=[], MESSAGES_MANUAL_RETRY_MAX_AGE=86400, # 1 day in seconds FRONTEND_SILENT_LOGIN_ENABLED=True, RELEASE="1.2.3", @@ -68,6 +69,7 @@ def test_api_config(is_authenticated): "MAX_RECIPIENTS_PER_MESSAGE": 42, "MAX_TEMPLATE_IMAGE_SIZE": 2097152, "IMAGE_PROXY_ENABLED": False, + "MESSAGE_TRUSTED_LINK_DOMAINS": [], "MESSAGES_MANUAL_RETRY_MAX_AGE": 86400, "FRONTEND_SILENT_LOGIN_ENABLED": True, } @@ -148,6 +150,14 @@ def test_api_config_frontend_settings(): ) +@override_settings(MESSAGE_TRUSTED_LINK_DOMAINS=["gouv.fr", "*.example.com"]) +def test_api_config_trusted_link_domains(): + """The trusted-link-domains allowlist should be exposed to the frontend.""" + response = APIClient().get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + assert response.json()["MESSAGE_TRUSTED_LINK_DOMAINS"] == ["gouv.fr", "*.example.com"] + + @override_settings( FEATURE_MAILDOMAIN_MANAGE_TOTP=True, KEYCLOAK_TOTP_ROLE_ID=None, diff --git a/src/backend/e2e/management/commands/e2e_demo.py b/src/backend/e2e/management/commands/e2e_demo.py index a277487c..1a4e0af1 100644 --- a/src/backend/e2e/management/commands/e2e_demo.py +++ b/src/backend/e2e/management/commands/e2e_demo.py @@ -10,6 +10,8 @@ from django.core.management.base import BaseCommand from django.db import transaction from django.utils import timezone +from jmap_email import parse_email + from core import models from core.enums import ( MailboxRoleChoices, @@ -18,6 +20,7 @@ from core.enums import ( ThreadAccessRoleChoices, ThreadEventTypeChoices, ) +from core.mda.inbound import deliver_inbound_message from core.services.identity.keycloak import get_keycloak_admin_client BROWSERS = ["chromium", "firefox", "webkit"] @@ -382,9 +385,10 @@ class Command(BaseCommand): "Inbox thread beta", ] - # Clean up existing inbox test threads + # Clean up existing inbox test threads (bodyless seeds + the link + # thread delivered separately below). existing = models.Thread.objects.filter( - subject__in=inbox_subjects, + subject__in=[*inbox_subjects, "Spam link inbox thread"], accesses__mailbox=mailbox, ) deleted_count = existing.count() @@ -418,10 +422,35 @@ class Command(BaseCommand): ) thread.update_stats() + self._create_inbox_link_thread(mailbox, browser) + self.stdout.write( self.style.SUCCESS(f" ✓ Inbox test data created for {browser}") ) + def _create_inbox_link_thread(self, mailbox, browser): + """Deliver a real inbound message with an HTML body carrying a bare + external URL, so the link-preview E2E test has a genuine received thread + to report as spam and check that its links get disabled. + """ + recipient = f"{mailbox.local_part}@{mailbox.domain.name}" + raw = ( + f"From: External Sender {browser} " + f"\r\n" + f"To: {recipient}\r\n" + f"Subject: Spam link inbox thread\r\n" + f"Message-ID: \r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + "\r\n" + f"

Do not click https://external-link.example/promo please

" + "\r\n" + ).encode() + + # is_import bypasses the spam pipeline so the thread lands in the inbox + # (the test is the one that reports it as spam). + deliver_inbound_message(recipient, parse_email(raw), raw, is_import=True) + def _create_thread_with_message(self, mailbox, sender_contact, subject, recipients): """ Create a thread with a message and recipients. diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 7f697e7e..5d33aaf5 100644 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -224,6 +224,18 @@ class Base(Configuration): environ_prefix=None, ) + # External-link confirmation allowlist: hostnames whose links open without + # the "you are about to leave this page" confirmation modal. A lone ``*`` + # entry trusts every host (disables the modal). A leading ``*.`` wildcard + # matches a domain and all its subdomains (``*.gouv.fr`` matches ``gouv.fr`` + # and ``impots.gouv.fr``); any other entry matches the host exactly + # (case-insensitive). Masked links, whose display text points to a different + # host than the real target, always prompt even when their target is listed + # here — that is the phishing case the modal exists for. + MESSAGE_TRUSTED_LINK_DOMAINS = values.ListValue( + [], environ_name="MESSAGE_TRUSTED_LINK_DOMAINS", environ_prefix=None + ) + # Security ALLOWED_HOSTS = values.ListValue([]) SECRET_KEY = values.Value(None) diff --git a/src/e2e/src/__tests__/link-preview.spec.ts b/src/e2e/src/__tests__/link-preview.spec.ts new file mode 100644 index 00000000..fee11939 --- /dev/null +++ b/src/e2e/src/__tests__/link-preview.spec.ts @@ -0,0 +1,122 @@ +import test, { expect, Page } from "@playwright/test"; +import { getMailboxEmail } from "../utils"; +import { signInKeycloakIfNeeded } from "../utils-test"; + +/** + * Link preview feature: URLs in a message body are rendered as links, a + * confirmation modal reveals the real target before opening it, and links + * are disabled on threads reported as spam. + */ + +// Reserved TLD (RFC 2606) so the link can never resolve for real; the +// navigation is served locally through a Playwright route. +const EXTERNAL_URL = "https://external-link.example/promo"; + +const composeAndSendMessage = async (page: Page, to: string, subject: string, body: string) => { + await page.getByRole("link", { name: "New message" }).click(); + await page.waitForURL("/mailbox/*/new"); + await page.getByRole("heading", { name: "New message" }).waitFor({ state: "visible" }); + + await page.getByRole("combobox", { name: "To" }).fill(to); + await page.getByRole("textbox", { name: "Subject" }).fill(subject); + await page.locator(".ProseMirror").pressSequentially(body); + await page.getByText("Draft saved").waitFor({ state: "visible" }); + + await page.getByRole("button", { name: "Send" }).click(); + await page.getByText("Message sent successfully").waitFor({ state: "visible" }); +}; + +test.describe("Message link preview", () => { + + test.beforeEach(async ({ page, browserName }) => { + await signInKeycloakIfNeeded({ page, username: `user.e2e.${browserName}` }); + await page.waitForLoadState("networkidle"); + }); + + test("should render bare URLs as links and ask for confirmation before opening", async ({ page, browserName }) => { + const subject = `Link preview test ${browserName}`; + + // Serve the external URL locally so the confirmed navigation succeeds + // in every browser without hitting the network. + await page.context().route(`${EXTERNAL_URL}**`, (route) => + route.fulfill({ contentType: "text/html", body: "External page" }) + ); + + await composeAndSendMessage( + page, + getMailboxEmail("user", browserName), + subject, + `Check this out ${EXTERNAL_URL} thanks` + ); + + // Open the sent message + await page.getByRole("link", { name: "Sent" }).click(); + await page.getByRole("option", { name: subject }).first().click(); + await page.getByRole("heading", { name: subject, level: 2 }).waitFor({ state: "visible" }); + + // The bare URL must be rendered as a clickable link in the message body. + // Scoped to the last message iframe: the latest message is the unfolded one. + const iframeContent = page.locator("iframe").last().contentFrame(); + const link = iframeContent.locator(`a[href="${EXTERNAL_URL}"]`).first(); + await expect(link).toBeVisible(); + + // Clicking the link opens a confirmation modal revealing the target URL + // instead of navigating directly + await link.click(); + const modal = page.getByRole("dialog", { name: "External link" }); + await expect(modal).toBeVisible(); + await expect(modal.getByText("You are about to leave this page and be redirected to:")).toBeVisible(); + await expect(modal.getByText(EXTERNAL_URL)).toBeVisible(); + await expect(modal.getByText("Do you want to continue?")).toBeVisible(); + + // Cancelling closes the modal without opening the link + await modal.getByRole("button", { name: "Cancel" }).click(); + await expect(modal).toBeHidden(); + + // Confirming opens the link in a new tab + await link.click(); + await expect(modal).toBeVisible(); + const popupPromise = page.context().waitForEvent("page"); + await modal.getByRole("button", { name: "Yes" }).click(); + const popup = await popupPromise; + await popup.waitForURL(EXTERNAL_URL); + await popup.close(); + await expect(modal).toBeHidden(); + }); + + test("should disable links on threads reported as spam", async ({ page }) => { + // A self-sent message is deduplicated and never reaches the inbox, so this + // thread is seeded by `e2e_demo` as a genuine inbound message (external + // sender, HTML body carrying EXTERNAL_URL as a bare link). + const subject = "Spam link inbox thread"; + + // Open the received thread from the inbox and report it as spam + await expect(async () => { + await page.getByRole("link", { name: "Inbox" }).click(); + await expect(page.getByRole("option", { name: subject }).first()).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 30000 }); + + await page.getByRole("option", { name: subject }).first().click(); + await page.getByRole("heading", { name: subject, level: 2 }).waitFor({ state: "visible" }); + await page.getByRole("button", { name: "Report as spam" }).click(); + await page.getByText(/has been reported as spam/).waitFor({ state: "visible" }); + + // Reopen the thread from the Spam folder + await expect(async () => { + await page.getByRole("link", { name: "Inbox" }).click(); + await page.getByRole("link", { name: "Spam" }).click(); + await expect(page.getByRole("option", { name: subject }).first()).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 30000 }); + + await page.getByRole("option", { name: subject }).first().click(); + await page.getByRole("heading", { name: subject, level: 2 }).waitFor({ state: "visible" }); + + // Links are inert: no pointer interaction and no confirmation modal + const iframeContent = page.locator("iframe").last().contentFrame(); + const link = iframeContent.locator(`a[href="${EXTERNAL_URL}"]`).first(); + await expect(link).toBeVisible(); + await expect(link).toHaveCSS("pointer-events", "none"); + await link.click({ force: true }); + await expect(page.getByRole("dialog")).toHaveCount(0); + }); +}); diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index efb960ba..483fab20 100755 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -340,6 +340,7 @@ "Display those images": "Display those images", "DNS": "DNS", "Do you have any feedback?": "Do you have any feedback?", + "Do you want to continue?": "Do you want to continue?", "Domain": "Domain", "Domain admin": "Domain admin", "Domain not found": "Domain not found", @@ -393,6 +394,7 @@ "Expand": "Expand", "Expand {{name}}": "Expand {{name}}", "Expand all": "Expand all", + "External link": "External link", "Failed to delete auto-reply.": "Failed to delete auto-reply.", "Failed to delete integration.": "Failed to delete integration.", "Failed to delete signature.": "Failed to delete signature.", @@ -774,6 +776,7 @@ "The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.": "The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.", "The redirect mailbox {{mailboxAddress}} has been created successfully.": "The redirect mailbox <1>{{mailboxAddress}} has been created successfully.", "The shared mailbox {{mailboxAddress}} has been created successfully.": "The shared mailbox <1>{{mailboxAddress}} has been created successfully.", + "The text of this link does not match its real target, it may be unsafe.": "The text of this link does not match its real target, it may be unsafe.", "The upload failed. Please try again.": "The upload failed. Please try again.", "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. Changes may take up to 24 hours to propagate. If you don't know how to update them, please contact your technical service provider or system administrator.": "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. Changes may take up to 24 hours to propagate. If you don't know how to update them, please contact your technical service provider or system administrator.", "These tags will be automatically applied to every incoming message from the widget.": "These tags will be automatically applied to every incoming message from the widget.", @@ -876,6 +879,7 @@ "You and {{assignees}} were unassigned_one": "You and {{assignees}} were unassigned", "You and {{assignees}} were unassigned_other": "You and {{assignees}} were unassigned", "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.", + "You are about to leave this page and be redirected to:": "You are about to leave this page and be redirected to:", "You are the last editor of this thread, you cannot therefore modify your access.": "You are the last editor of this thread, you cannot therefore modify your access.", "You assigned {{assignees}}_one": "You assigned {{assignees}}", "You assigned {{assignees}}_other": "You assigned {{assignees}}", diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index 16f4f395..24d694ce 100755 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -413,6 +413,7 @@ "Display those images": "Afficher ces images", "DNS": "DNS", "Do you have any feedback?": "Partager un retour ou une question", + "Do you want to continue?": "Voulez-vous continuer ?", "Domain": "Domaine", "Domain admin": "Gestion des domaines", "Domain not found": "Domaine introuvable", @@ -470,6 +471,7 @@ "Expand": "Développer", "Expand {{name}}": "Développer {{name}}", "Expand all": "Tout développer", + "External link": "Lien externe", "Failed to delete auto-reply.": "Erreur lors de la suppression de la réponse automatique.", "Failed to delete integration.": "Erreur lors de la suppression de l'intégration.", "Failed to delete signature.": "Erreur lors de la suppression de la signature.", @@ -861,6 +863,7 @@ "The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.": "L'archive PST est illisible : le fichier est corrompu ou sa structure interne est incomplète. Réessayer ne servira à rien — veuillez essayer de régénérer l'archive.", "The redirect mailbox {{mailboxAddress}} has been created successfully.": "L'adresse de redirection {{mailboxAddress}} a été créée avec succès.", "The shared mailbox {{mailboxAddress}} has been created successfully.": "L'adresse partagée {{mailboxAddress}} a été créée avec succès.", + "The text of this link does not match its real target, it may be unsafe.": "Le texte de ce lien ne correspond pas à sa cible réelle, il est peut-être dangereux.", "The upload failed. Please try again.": "Le téléversement a échoué. Veuillez réessayer.", "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. Changes may take up to 24 hours to propagate. If you don't know how to update them, please contact your technical service provider or system administrator.": "Ces enregistrements DNS doivent être configurés sur le domaine {{domain}} pour que le système de messagerie fonctionne correctement. Les changements peuvent prendre jusqu'à 24 heures pour être propagés. Si vous ne savez pas comment les mettre à jour, contactez votre prestataire technique ou votre administrateur système.", "These tags will be automatically applied to every incoming message from the widget.": "Ces libellés seront automatiquement appliqués à chaque message entrant provenant du widget.", @@ -965,6 +968,7 @@ "You and {{assignees}} were unassigned_many": "Vous et {{assignees}} avez été désassigné·e·s", "You and {{assignees}} were unassigned_other": "Vous et {{assignees}} avez été désassigné·e·s", "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "Vous et tous les utilisateurs avec un accès à la boîte « {{mailboxName}} » ne pourront plus voir cette conversation.", + "You are about to leave this page and be redirected to:": "Vous êtes sur le point de quitter cette page et d'être redirigé vers :", "You are the last editor of this thread, you cannot therefore modify your access.": "Vous êtes le dernier éditeur de cette conversation, vous ne pouvez donc pas modifier votre accès.", "You assigned {{assignees}}_one": "Vous avez assigné {{assignees}}", "You assigned {{assignees}}_many": "Vous avez assigné {{assignees}}", diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200.ts b/src/frontend/src/features/api/gen/models/config_retrieve200.ts index 35db8c87..3b574e37 100644 --- a/src/frontend/src/features/api/gen/models/config_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/config_retrieve200.ts @@ -35,6 +35,8 @@ export type ConfigRetrieve200 = { readonly MAX_TEMPLATE_IMAGE_SIZE: number; /** Whether external images should be proxied */ readonly IMAGE_PROXY_ENABLED: boolean; + /** Hostnames whose external links skip the redirect confirmation modal (a leading *. wildcard also matches subdomains) */ + readonly MESSAGE_TRUSTED_LINK_DOMAINS: readonly string[]; readonly FEATURE_MAILDOMAIN_CREATE: boolean; readonly FEATURE_MAILDOMAIN_MANAGE_ACCESSES: boolean; readonly FEATURE_THREAD_SPLIT: boolean; diff --git a/src/frontend/src/features/blocknote/email-exporter/index.test.tsx b/src/frontend/src/features/blocknote/email-exporter/index.test.tsx index f4608c58..6aa7d0b8 100644 --- a/src/frontend/src/features/blocknote/email-exporter/index.test.tsx +++ b/src/frontend/src/features/blocknote/email-exporter/index.test.tsx @@ -276,7 +276,6 @@ describe('EmailExporter', () => { ]); expect(html).toContain(' { expect(html).toContain('href="https://example.com/"'); }); - it('defaults the color to link blue when the text has no color', () => { - const html = exportBlocks([ - paragraph([link('https://example.com/', 'Click here')]), - ]); - expect(html).toContain('color:#0b6e99'); - }); - it('mirrors the text color onto the so the underline matches', () => { const coloredLink: AnyInlineContent = { type: 'link', diff --git a/src/frontend/src/features/blocknote/email-exporter/index.tsx b/src/frontend/src/features/blocknote/email-exporter/index.tsx index fd1ece7e..248f4a13 100644 --- a/src/frontend/src/features/blocknote/email-exporter/index.tsx +++ b/src/frontend/src/features/blocknote/email-exporter/index.tsx @@ -206,13 +206,13 @@ function renderInlineContent(content: AnyInlineContent[]): React.ReactNode[] { const textColor = link.content .map((st) => st.styles?.textColor as string | undefined) .find((color) => color && color !== 'default'); - const linkColor = textColor ? (COLORS[textColor]?.text || textColor) : '#0b6e99'; + const linkColor = textColor && (COLORS[textColor]?.text || textColor); return ( {renderedContent} diff --git a/src/frontend/src/features/config/resolve.ts b/src/frontend/src/features/config/resolve.ts index c9187038..e84e04b9 100644 --- a/src/frontend/src/features/config/resolve.ts +++ b/src/frontend/src/features/config/resolve.ts @@ -235,6 +235,7 @@ export const resolveConfig = (api?: ConfigRetrieve200): AppConfig => { MAX_RECIPIENTS_PER_MESSAGE: api?.MAX_RECIPIENTS_PER_MESSAGE ?? 500, MAX_TEMPLATE_IMAGE_SIZE: api?.MAX_TEMPLATE_IMAGE_SIZE ?? 2 * 1024 ** 2, IMAGE_PROXY_ENABLED: api?.IMAGE_PROXY_ENABLED ?? false, + MESSAGE_TRUSTED_LINK_DOMAINS: api?.MESSAGE_TRUSTED_LINK_DOMAINS ?? [], FEATURE_MAILDOMAIN_CREATE: api?.FEATURE_MAILDOMAIN_CREATE ?? true, FEATURE_MAILDOMAIN_MANAGE_ACCESSES: api?.FEATURE_MAILDOMAIN_MANAGE_ACCESSES ?? true, diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/_index.scss index f603c1ba..ebe79e88 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/_index.scss @@ -266,3 +266,23 @@ margin-top: calc(var(--c--globals--spacings--xs) * -1); margin-bottom: var(--c--globals--spacings--xs); } + +// Link confirmation modal (rendered in a portal, hence the global scope). +// Same layout as the ui-kit file preview external link modal. +.link-preview__content { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--xs); + text-align: left; + + .link-preview__url { + margin: 0; + white-space: pre-wrap; + word-break: break-all; + padding: var(--c--globals--spacings--2xs); + background-color: var(--c--contextuals--background--semantic--neutral--tertiary); + border-radius: 4px; + font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + font-size: var(--c--globals--font--sizes--sm); + } +} diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/is-host-trusted.test.ts b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/is-host-trusted.test.ts new file mode 100644 index 00000000..5fdc246c --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/is-host-trusted.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { isHostTrusted } from "./is-host-trusted"; + +describe("isHostTrusted", () => { + it("should not trust anything when the allowlist is empty", () => { + expect(isHostTrusted("example.com", [])).toBe(false); + }); + + it("should trust every host when the allowlist is a lone '*'", () => { + expect(isHostTrusted("anything.example", ["*"])).toBe(true); + expect(isHostTrusted("evil.test", ["*"])).toBe(true); + }); + + it("should match an exact host", () => { + expect(isHostTrusted("gouv.fr", ["gouv.fr"])).toBe(true); + }); + + it("should not match a subdomain for a non-wildcard pattern", () => { + expect(isHostTrusted("impots.gouv.fr", ["gouv.fr"])).toBe(false); + }); + + it("should match subdomains for a wildcard pattern", () => { + expect(isHostTrusted("impots.gouv.fr", ["*.gouv.fr"])).toBe(true); + }); + + it("should match the bare domain for a wildcard pattern", () => { + expect(isHostTrusted("gouv.fr", ["*.gouv.fr"])).toBe(true); + }); + + it("should not treat a wildcard as a plain suffix", () => { + // "*.gouv.fr" must not match a host that merely ends with "gouv.fr" + // without a dot boundary. + expect(isHostTrusted("evilgouv.fr", ["*.gouv.fr"])).toBe(false); + }); + + it("should match case-insensitively", () => { + expect(isHostTrusted("Example.COM", ["example.com"])).toBe(true); + expect(isHostTrusted("FOO.Example.com", ["*.EXAMPLE.com"])).toBe(true); + }); + + it("should match when any pattern in the list matches", () => { + expect(isHostTrusted("example.com", ["gouv.fr", "example.com"])).toBe(true); + }); + + it("should ignore blank patterns", () => { + expect(isHostTrusted("example.com", ["", " "])).toBe(false); + }); +}); diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/is-host-trusted.ts b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/is-host-trusted.ts new file mode 100644 index 00000000..ec846858 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/is-host-trusted.ts @@ -0,0 +1,30 @@ +/** + * Match a URL host against the backend-configured trusted-domain allowlist + * (the `MESSAGE_TRUSTED_LINK_DOMAINS` config entry). Hosts that match skip the + * external-link confirmation modal. + * + * A lone `*` pattern trusts every host. A pattern may otherwise use a single + * leading `*.` wildcard to also cover subdomains (`*.gouv.fr` matches + * `gouv.fr` and `impots.gouv.fr`); any other pattern matches the host exactly. + * Matching is case-insensitive. + */ +export function isHostTrusted(host: string, patterns: readonly string[]): boolean { + if (!host || patterns.length === 0) { + return false; + } + const normalizedHost = host.toLowerCase(); + return patterns.some((pattern) => { + const normalized = pattern.trim().toLowerCase(); + if (!normalized) { + return false; + } + if (normalized === "*") { + return true; + } + if (normalized.startsWith("*.")) { + const base = normalized.slice(2); + return normalizedHost === base || normalizedHost.endsWith(`.${base}`); + } + return normalizedHost === normalized; + }); +} diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/renderers/linkify.test.ts b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/renderers/linkify.test.ts new file mode 100644 index 00000000..f617a9c6 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/renderers/linkify.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { linkifyHtml } from "./linkify"; + +describe("linkifyHtml", () => { + it("should return empty content unchanged", () => { + expect(linkifyHtml("")).toBe(""); + }); + + it("should leave text without URL untouched", () => { + const html = "

Hello world

"; + expect(linkifyHtml(html)).toBe(html); + }); + + it("should wrap a bare http(s) URL in an anchor", () => { + expect(linkifyHtml("

See https://example.com/page for details

")).toBe( + '

See https://example.com/page for details

' + ); + }); + + it("should prefix www URLs with https", () => { + expect(linkifyHtml("

Visit www.example.com now

")).toBe( + '

Visit www.example.com now

' + ); + }); + + it("should linkify several URLs in the same text node", () => { + const result = linkifyHtml("

https://a.example and https://b.example

"); + expect(result).toContain('href="https://a.example"'); + expect(result).toContain('href="https://b.example"'); + expect(result).toContain(" and { + const result = linkifyHtml("

https://a.example,https://b.example

"); + expect(result).toContain('href="https://a.example"'); + expect(result).toContain('href="https://b.example"'); + expect(result).toContain(", { + const html = '

https://displayed.example

'; + expect(linkifyHtml(html)).toBe(html); + }); + + it("should not linkify inside style or script contents", () => { + const html = "

text

"; + expect(linkifyHtml(html)).toBe(html); + }); + + it("should exclude trailing sentence punctuation from the URL", () => { + expect(linkifyHtml("

Go to https://example.com/page.

")).toBe( + '

Go to https://example.com/page.

' + ); + }); + + it("should exclude a closing parenthesis wrapping the URL", () => { + expect(linkifyHtml("

(see https://example.com/page)

")).toBe( + '

(see https://example.com/page)

' + ); + }); + + it("should keep balanced parentheses inside the URL", () => { + expect(linkifyHtml("

https://en.wikipedia.org/wiki/Test_(unit)

")).toBe( + '

https://en.wikipedia.org/wiki/Test_(unit)

' + ); + }); + + it("should linkify URLs in nested markup while preserving structure", () => { + const result = linkifyHtml("

intro

quoted https://example.com text
"); + expect(result).toBe( + '

intro

quoted https://example.com text
' + ); + }); +}); diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/renderers/linkify.ts b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/renderers/linkify.ts new file mode 100644 index 00000000..332a2da4 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/renderers/linkify.ts @@ -0,0 +1,129 @@ +/** + * Linkifier for rendered message bodies. + * + * Detects bare URLs living in text nodes (text that is not already wrapped + * in a link) and turns them into anchor elements so they become clickable. + * It must run AFTER sanitization: it only ever adds anchors whose href comes + * from an http(s) URL matched in plain text. + */ + +// A comma stops the match so that comma-separated URLs pasted without a +// space (https://a,https://b) split into distinct links instead of merging +// into one broken token. +const URL_PATTERN = /(?:https?:\/\/|www\.)[^\s<>"',]+/gi; + +// Text content of those elements must never be linkified. +const SKIPPED_TAGS = new Set(["A", "STYLE", "SCRIPT", "TEXTAREA", "TITLE", "BUTTON", "SELECT", "OPTION", "NOSCRIPT", "CODE", "PRE", "SVG"]); + +// Punctuation following a URL in a sentence is not part of the URL itself. +const TRAILING_PUNCTUATION = new Set([".", ";", ":", "!", "?", "'", '"', "”", "’", "»", ")", "]", "}"]); + +function hasSkippedAncestor(node: Node): boolean { + for (let element = node.parentElement; element; element = element.parentElement) { + if (SKIPPED_TAGS.has(element.tagName)) { + return true; + } + } + return false; +} + +/** + * Strip sentence punctuation stuck to the end of a matched URL. + * Closing parentheses are kept as long as they balance an opening + * one inside the URL (e.g. wikipedia.org/wiki/Test_(unit)). + */ +function trimTrailingPunctuation(url: string): string { + let end = url.length; + while (end > 0) { + const char = url[end - 1]; + if (!TRAILING_PUNCTUATION.has(char)) { + break; + } + if (char === ")") { + const candidate = url.slice(0, end); + const openCount = (candidate.match(/\(/g) ?? []).length; + const closeCount = (candidate.match(/\)/g) ?? []).length; + if (closeCount <= openCount) { + break; + } + } + end -= 1; + } + return url.slice(0, end); +} + +function createAnchor(doc: Document, url: string): HTMLAnchorElement { + const anchor = doc.createElement("a"); + anchor.setAttribute("href", /^https?:\/\//i.test(url) ? url : `https://${url}`); + anchor.setAttribute("target", "_blank"); + anchor.setAttribute("rel", "noopener noreferrer"); + anchor.textContent = url; + return anchor; +} + +/** + * Split a text node content into a fragment mixing plain text and anchors. + * Returns null when the text contains no URL, so the caller can keep the + * original node untouched. + */ +function linkifyTextNode(doc: Document, text: string): DocumentFragment | null { + const matches = Array.from(text.matchAll(URL_PATTERN)); + if (matches.length === 0) { + return null; + } + + const fragment = doc.createDocumentFragment(); + let cursor = 0; + + matches.forEach((match) => { + const url = trimTrailingPunctuation(match[0]); + if (!url) { + return; + } + if (match.index > cursor) { + fragment.appendChild(doc.createTextNode(text.slice(cursor, match.index))); + } + fragment.appendChild(createAnchor(doc, url)); + cursor = match.index + url.length; + }); + + if (cursor === 0) { + return null; + } + if (cursor < text.length) { + fragment.appendChild(doc.createTextNode(text.slice(cursor))); + } + return fragment; +} + +/** + * Turn bare URLs found in text nodes of an HTML string into clickable links. + * URLs already wrapped in an anchor are left untouched. + */ +export function linkifyHtml(html: string): string { + if (!html) { + return html; + } + + const doc = new DOMParser().parseFromString(html, "text/html"); + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); + const textNodes: Text[] = []; + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if (!hasSkippedAncestor(node)) { + textNodes.push(node); + } + } + + let hasChanged = false; + textNodes.forEach((node) => { + const fragment = linkifyTextNode(doc, node.data); + if (fragment) { + node.replaceWith(fragment); + hasChanged = true; + } + }); + + // Avoid a needless re-serialization drift when nothing matched. + return hasChanged ? doc.body.innerHTML : html; +} diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx index 6f56c6c6..67fcc8a1 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx @@ -13,6 +13,9 @@ import { Banner } from "@/features/ui/components/banner"; import { getMailboxesImageProxyListUrl } from "@/features/api/gen/mailboxes/mailboxes"; import { EXTERNAL_IMAGES_CONSENT_KEY } from "@/features/config/constants"; import { renderBodyParts } from "./renderers"; +import { linkifyHtml } from "./renderers/linkify"; +import { isHostTrusted } from "./is-host-trusted"; +import { useLinkConfirmation } from "./use-link-confirmation"; import { ThreadMessageBodyProps } from "./types"; const CSP = [ @@ -43,12 +46,38 @@ const CSP = [ "frame-ancestors 'none'", ].join('; '); +/** + * Detect link masking: the link text pretends to be a URL but points to a + * different host than the real target, a common phishing technique + * (e.g. http://safe.fr). + */ +const isLinkMasked = (href: string, text: string): boolean => { + const trimmedText = text.trim(); + if (!/^(https?:\/\/|www\.)/i.test(trimmedText)) { + return false; + } + try { + const textUrl = new URL(/^https?:\/\//i.test(trimmedText) ? trimmedText : `https://${trimmedText}`); + const hrefUrl = new URL(href); + return textUrl.host.replace(/^www\./, "") !== hrefUrl.host.replace(/^www\./, ""); + } catch { + // The text pretends to be a URL but one side cannot be parsed, stay cautious. + return true; + } +}; + const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, messageId, onLoad }: ThreadMessageBodyProps) => { const { t } = useTranslation(); const iframeRef = useRef(null); const { cunninghamTheme, variant } = useTheme(); - const { selectedMailbox } = useMailboxContext(); - const { IMAGE_PROXY_ENABLED: canDisplayExternalImages } = useConfig(); + const { selectedMailbox, selectedThread } = useMailboxContext(); + const confirmLinkOpening = useLinkConfirmation(); + // Links of spam messages are inert. A ref keeps the iframe click handler + // in sync without having to re-attach listeners when the flag changes. + const areLinksDisabled = Boolean(selectedThread?.is_spam); + const areLinksDisabledRef = useRef(areLinksDisabled); + areLinksDisabledRef.current = areLinksDisabled; + const { IMAGE_PROXY_ENABLED: canDisplayExternalImages, MESSAGE_TRUSTED_LINK_DOMAINS: trustedLinkDomains } = useConfig(); const [displayExternalImages, setDisplayExternalImages] = useState(() => { const consentMessageIds = sessionStorage.getItem(EXTERNAL_IMAGES_CONSENT_KEY); if (consentMessageIds) { @@ -98,6 +127,8 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess const hasHtmlPart = bodyParts.some(part => part.type === "text/html"); const hasOnlyPlainText = bodyParts.every(part => part.type === "text/plain"); + let htmlContent = renderedContent; + if (hasHtmlPart) { // Process HTML content with UnquoteMessage for quote detection const unquoteMessage = new UnquoteMessage(renderedContent, '', { @@ -105,10 +136,8 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess ignoreFirstForward: true, depth: 0, }); - return unquoteMessage.getHtml().content; - } - - if (hasOnlyPlainText) { + htmlContent = unquoteMessage.getHtml().content; + } else if (hasOnlyPlainText) { // Pure plain text - process original content through UnquoteMessage for quote detection const rawTextContent = bodyParts.map(part => part.content).join("\n"); const unquoteMessage = new UnquoteMessage('', rawTextContent, { @@ -118,11 +147,12 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess }); const unquotedText = unquoteMessage.getText().content; - return renderToStaticMarkup(

{unquotedText}

); + htmlContent = renderToStaticMarkup(

{unquotedText}

); } + // Otherwise, mixed content (plain text + images, etc.) - use rendered content directly - // Mixed content (plain text + images, etc.) - use rendered content directly - return renderedContent; + // Turn bare URLs (text not already wrapped in a link) into anchors + return linkifyHtml(htmlContent); }, [bodyParts, cidToBlobUrlMap, externalImageOptions]); const wrappedHtml = useMemo(() => { @@ -170,6 +200,14 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess text-decoration: none; } a:hover { text-decoration: underline; } + ${areLinksDisabled ? ` + /* Spam: links are inert and rendered as plain text */ + a, a:hover { + pointer-events: none; + color: inherit !important; + text-decoration: none !important; + cursor: default; + }` : ''} blockquote { padding: 0 ${tokens.themes[cunninghamTheme].globals.spacings.base} !important; @@ -303,7 +341,7 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess `; - }, [sanitizedHtmlBody, cunninghamTheme, variant]); + }, [sanitizedHtmlBody, cunninghamTheme, variant, areLinksDisabled]); const resizeIframe = useCallback(() => { if (iframeRef.current?.contentWindow?.document.documentElement) { @@ -312,6 +350,51 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess } }, [iframeRef]); + // Delegated handler for link activations inside the iframe (mouse click, + // keyboard activation and middle click through the auxclick event). + const handleLinkActivation = useCallback((event: MouseEvent) => { + const target = event.target as Element | null; + const link = target?.closest?.('a[href]') as HTMLAnchorElement | null; + if (!link) { + return; + } + + // In-page anchors are safe and stay within the iframe + if (link.getAttribute('href')?.startsWith('#')) { + return; + } + + if (areLinksDisabledRef.current) { + event.preventDefault(); + return; + } + + // Let other protocols (mailto:, tel:, ...) follow their default behavior + if (!/^https?:/i.test(link.href)) { + return; + } + + event.preventDefault(); + // Middle clicks are only blocked, the confirmation flow targets primary clicks + if (event.type === 'auxclick') { + return; + } + + const url = link.href; + const isMasked = isLinkMasked(url, link.textContent ?? ''); + // Trusted domains skip the confirmation, unless the link is masked: a + // masked link is exactly the phishing case the confirmation exists for. + if (!isMasked && isHostTrusted(new URL(url).hostname, trustedLinkDomains)) { + window.open(url, '_blank', 'noopener,noreferrer'); + return; + } + void confirmLinkOpening(url, isMasked).then((confirmed) => { + if (confirmed) { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }); + }, [confirmLinkOpening, trustedLinkDomains]); + const handleIframeLoad = useCallback(() => { resizeIframe(); if (iframeRef.current?.contentWindow?.document) { @@ -321,9 +404,13 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess doc.querySelectorAll('details.email-quoted-content').forEach(node => { node.addEventListener('toggle', resizeIframe); }); + + // Intercept link activations to reveal the real target before opening it + doc.addEventListener('click', handleLinkActivation); + doc.addEventListener('auxclick', handleLinkActivation); } onLoad?.(); - }, [onLoad, resizeIframe]); + }, [onLoad, resizeIframe, handleLinkActivation]); useEffect(() => { const handleMessage = (event: MessageEvent) => { diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/use-link-confirmation.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/use-link-confirmation.tsx new file mode 100644 index 00000000..915973f9 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/use-link-confirmation.tsx @@ -0,0 +1,38 @@ +import { useCallback } from "react"; +import { useModals } from "@gouvfr-lasuite/cunningham-react"; +import { useTranslation } from "react-i18next"; +import { Banner } from "@/features/ui/components/banner"; + +/** + * Ask the user to confirm opening an external link from a message, + * revealing its real target URL to protect against link masking phishing + * attempts (e.g. http://safe.fr). + * + * Uses the same confirmation modal layout as the file preview when + * clicking a link inside a PDF. + * + * @returns an async function resolving to true when the user confirms + */ +export const useLinkConfirmation = () => { + const { t } = useTranslation(); + const modals = useModals(); + + return useCallback(async (url: string, isMasked: boolean = false): Promise => { + const decision = await modals.confirmationModal({ + title: t("External link"), + children: ( +
+ {isMasked && ( + + {t("The text of this link does not match its real target, it may be unsafe.")} + + )} +

{t("You are about to leave this page and be redirected to:")}

+
{url}
+

{t("Do you want to continue?")}

+
+ ), + }); + return decision === "yes"; + }, [modals, t]); +}; diff --git a/src/frontend/src/features/ui/components/banner/_index.scss b/src/frontend/src/features/ui/components/banner/_index.scss index dfa4c4ea..9d852eda 100644 --- a/src/frontend/src/features/ui/components/banner/_index.scss +++ b/src/frontend/src/features/ui/components/banner/_index.scss @@ -67,13 +67,16 @@ } .banner--compact { - padding: var(--c--globals--spacings--xs); - font-size: var(--c--globals--font--sizes--sm); - + padding: var(--c--globals--spacings--2xs); + .banner__icon { font-size: 1rem; height: 16px; } + + .banner__content { + font-size: var(--c--globals--font--sizes--sm); + } } .banner--full-width {