From e9f90e95b162e93824fe70bc82bf45a7a61830c1 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Tue, 24 Mar 2026 20:18:17 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F(backend)=20fix=20email=20?= =?UTF-8?q?disclosure=20in=20room=20invitation=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent invited participants from seeing each other's email addresses when sending room invitations. Ensure invitations are sent with proper isolation to avoid mass PII disclosure. This mitigates risks of email harvesting, spam, and phishing through the platform. --- CHANGELOG.md | 4 ++ src/backend/core/services/invitation.py | 21 +++++---- .../core/tests/rooms/test_api_rooms_invite.py | 46 +++++++++++-------- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4114c150..7be4e791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Fixed + +- 🔒️(backend) fix email disclosure in room invitation endpoint #1200 + ## [1.12.0] - 2026-03-24 ### Changed diff --git a/src/backend/core/services/invitation.py b/src/backend/core/services/invitation.py index 94d9a301..05ddad59 100644 --- a/src/backend/core/services/invitation.py +++ b/src/backend/core/services/invitation.py @@ -4,7 +4,7 @@ import smtplib from logging import getLogger from django.conf import settings -from django.core.mail import send_mail +from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils.translation import get_language, override from django.utils.translation import gettext_lazy as _ @@ -45,15 +45,18 @@ class InvitationService: ) ) # Force translation + email = EmailMultiAlternatives( + subject=subject, + body=msg_plain, + from_email=settings.EMAIL_FROM, + to=[], + bcc=emails, + ) + + email.attach_alternative(msg_html, "text/html") + try: - send_mail( - subject, - msg_plain, - settings.EMAIL_FROM, - emails, - html_message=msg_html, - fail_silently=False, - ) + email.send() except smtplib.SMTPException as e: logger.error("invitation to %s was not sent: %s", emails, e) raise InvitationError("Could not send invitation") from e diff --git a/src/backend/core/tests/rooms/test_api_rooms_invite.py b/src/backend/core/tests/rooms/test_api_rooms_invite.py index 1bc91ede..5d031833 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_invite.py +++ b/src/backend/core/tests/rooms/test_api_rooms_invite.py @@ -240,10 +240,9 @@ def test_api_rooms_invite_error(mock_invite_to_room): mock_invite_to_room.assert_called_once() -@mock.patch("core.services.invitation.send_mail") -def test_api_rooms_invite_success(mock_send_mail, settings): +@mock.patch("core.services.invitation.EmailMultiAlternatives") +def test_api_rooms_invite_success(mock_email_class, settings): """Test privileged users should successfully send invitation emails.""" - settings.EMAIL_BRAND_NAME = "ACME" settings.EMAIL_LOGO_IMG = "https://acme.com/logo" settings.EMAIL_APP_BASE_URL = "https://acme.com" @@ -255,7 +254,6 @@ def test_api_rooms_invite_success(mock_send_mail, settings): user = UserFactory() room.accesses.create(user=user, role=random.choice(["administrator", "owner"])) - client.force_login(user) data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]} @@ -269,26 +267,38 @@ def test_api_rooms_invite_success(mock_send_mail, settings): assert response.status_code == 200 assert response.json() == {"status": "success", "message": "invitations sent"} - mock_send_mail.assert_called_once() + mock_email_class.assert_called_once() - subject, body, sender, recipients = mock_send_mail.call_args[0] + # Check constructor arguments + call_kwargs = mock_email_class.call_args[1] # EmailMultiAlternatives(**kwargs) - assert ( - subject == f"Video call in progress: {user.email} is waiting for you to connect" + assert call_kwargs["subject"] == ( + f"Video call in progress: {user.email} is waiting for you to connect" + ) + assert call_kwargs["from_email"] == "notifications@acme.com" + assert call_kwargs["to"] == [] + assert sorted(call_kwargs["bcc"]) == sorted( + ["fabien@yopmail.com", "gerald@yopmail.com"] ) - # Verify email contains expected content + # Check plain text body + plain_body = call_kwargs["body"] required_content = [ - "ACME", # Brand name - "https://acme.com/logo", # Logo URL - f"https://acme.com/{room.slug}", # Room url - f"acme.com/{room.slug}", # Room link + "ACME", + "https://acme.com/logo", + f"https://acme.com/{room.slug}", + f"acme.com/{room.slug}", ] - for content in required_content: - assert content in body + assert content in plain_body - assert sender == "notifications@acme.com" + # Check HTML alternative was attached + mock_instance = mock_email_class.return_value + mock_instance.attach_alternative.assert_called_once() + html_body, mimetype = mock_instance.attach_alternative.call_args[0] + assert mimetype == "text/html" + for content in required_content: + assert content in html_body - # Verify all owners received the email (order-independent comparison) - assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"]) + # Check send was called + mock_instance.send.assert_called_once()