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()