From 7cf39850e6919ec8ebbb82e3921108174aebc739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 17 Sep 2026 15:20:02 +0200 Subject: [PATCH] feat: email recipient whitelist --- .env | 8 + config/services.yaml | 13 ++ .../EmailRecipientWhitelistSubscriber.php | 140 +++++++++++++++++ .../EmailRecipientWhitelistSubscriberTest.php | 147 ++++++++++++++++++ 4 files changed, 308 insertions(+) create mode 100644 src/EventListener/EmailRecipientWhitelistSubscriber.php create mode 100644 tests/EventListener/EmailRecipientWhitelistSubscriberTest.php diff --git a/.env b/.env index 7dec6e4..368d188 100644 --- a/.env +++ b/.env @@ -70,6 +70,14 @@ APP_BPN_CRM_ID_TEAMER=1070 APP_DEFAULT_EMAIL_FROM=team@ep-reisen.de APP_DEFAULT_EMAIL_TO=team@ep-reisen.de +# Regular expressions of the only recipients a mail may actually reach. Everything else is +# stripped from the envelope and a mail left without a recipient is not sent at all, see +# App\EventListener\EmailRecipientWhitelistSubscriber. Empty means no filtering, which is what +# production runs with. Set this on staging: it runs the real transports against real teamer +# addresses. The patterns have to match the full address. +# Example: APP_MAIL_RECIPIENT_WHITELIST='.*@ep-reisen\.de,qa@example\.com' +APP_MAIL_RECIPIENT_WHITELIST= + SFTP_XML_EXPORT_HOST= SFTP_XML_EXPORT_PORT=22 SFTP_XML_EXPORT_USER= diff --git a/config/services.yaml b/config/services.yaml index d214e22..6f6dc3f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -48,6 +48,12 @@ parameters: default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' + # Per deployment and not per environment: staging and production run the same compiled + # environment and differ only in their .env.local. The csv processor turns an unset + # variable into an empty list, which switches the filtering off - so production is + # unaffected without anything having to be remembered at deploy time. + mail_recipient_whitelist: '%env(csv:APP_MAIL_RECIPIENT_WHITELIST)%' + teamer_inactive_period: '-2 years' # The stand-in PDF served instead of real uploads while the upload_replacement feature is @@ -362,6 +368,13 @@ services: $logger: '@monolog.logger.api' tags: [ 'controller.service_arguments' ] + App\EventListener\EmailRecipientWhitelistSubscriber: + arguments: + $allowedRecipients: '%mail_recipient_whitelist%' + # Not the audit channel this argument is bound to by default: a mail dropped by a + # staging guard is a technical event and has no place in the application log table. + $logger: '@logger' + App\Security\OAuth2\MyEpClient: arguments: $options: diff --git a/src/EventListener/EmailRecipientWhitelistSubscriber.php b/src/EventListener/EmailRecipientWhitelistSubscriber.php new file mode 100644 index 0000000..976c635 --- /dev/null +++ b/src/EventListener/EmailRecipientWhitelistSubscriber.php @@ -0,0 +1,140 @@ + '' !== $pattern)); + + foreach ($patterns as $pattern) { + // Checked once, here, rather than per address: an invalid pattern makes preg_match() + // return false for every address, which looks exactly like "nobody is whitelisted" + // and eats the mail. Blowing up on the first send is the louder failure. + if (false === @preg_match($this->toRegex($pattern), '')) { + throw new \InvalidArgumentException(\sprintf('Invalid recipient whitelist pattern "%s": it is not a valid regular expression.', $pattern)); + } + } + + $this->allowedRecipients = $patterns; + } + + public static function getSubscribedEvents(): array + { + return [ + // After MessageListener, which applies the configured default headers - a Bcc + // added there has to be filtered too - and before EnvelopeListener and + // MessageLoggerListener at -255, so a dropped mail is not recorded as sent. + MessageEvent::class => ['onMessage', -250], + ]; + } + + public function onMessage(MessageEvent $event): void + { + if (!$this->allowedRecipients) { + return; + } + + $envelope = $event->getEnvelope(); + $recipients = $envelope->getRecipients(); + + $kept = []; + $stripped = []; + + foreach ($recipients as $recipient) { + if ($this->isAllowed($recipient)) { + $kept[] = $recipient; + } else { + $stripped[] = $recipient->getAddress(); + } + } + + if (!$stripped) { + return; + } + + if (!$kept) { + $this->logger->info('Mail dropped: no whitelisted recipient', [ + 'recipients' => $stripped, + 'subject' => $this->subjectOf($event), + ]); + + $event->reject(); + + return; + } + + $envelope->setRecipients($kept); + + $this->logger->info('Mail recipients stripped: not whitelisted', [ + 'stripped' => $stripped, + 'kept' => array_map(static fn (Address $address) => $address->getAddress(), $kept), + 'subject' => $this->subjectOf($event), + ]); + } + + private function isAllowed(Address $recipient): bool + { + foreach ($this->allowedRecipients as $pattern) { + if (preg_match($this->toRegex($pattern), $recipient->getAddress())) { + return true; + } + } + + return false; + } + + /** + * Anchored the way Symfony\Component\Mailer\EventListener\EnvelopeListener anchors its own + * patterns: a pattern always has to match the whole address. + */ + private function toRegex(string $pattern): string + { + return '{\A'.$pattern.'\z}i'; + } + + private function subjectOf(MessageEvent $event): ?string + { + $message = $event->getMessage(); + + return method_exists($message, 'getSubject') ? $message->getSubject() : null; + } +} diff --git a/tests/EventListener/EmailRecipientWhitelistSubscriberTest.php b/tests/EventListener/EmailRecipientWhitelistSubscriberTest.php new file mode 100644 index 0000000..516390c --- /dev/null +++ b/tests/EventListener/EmailRecipientWhitelistSubscriberTest.php @@ -0,0 +1,147 @@ +event(['team@ep-reisen.de', 'qa@example.com']); + + $this->subscriber(['.*@ep-reisen\.de', 'qa@example\.com'])->onMessage($event); + + self::assertFalse($event->isRejected()); + self::assertSame( + ['team@ep-reisen.de', 'qa@example.com'], + $this->addresses($event) + ); + } + + public function testStripsTheRecipientsThatAreNotWhitelisted(): void + { + $event = $this->event(['teamer@gmail.com', 'team@ep-reisen.de', 'other@gmx.de']); + + $this->subscriber(['.*@ep-reisen\.de'])->onMessage($event); + + self::assertFalse($event->isRejected()); + self::assertSame(['team@ep-reisen.de'], $this->addresses($event)); + } + + public function testRejectsTheMailWhenNoRecipientIsWhitelisted(): void + { + $event = $this->event(['teamer@gmail.com', 'other@gmx.de']); + + $this->subscriber(['.*@ep-reisen\.de'])->onMessage($event); + + self::assertTrue($event->isRejected()); + } + + public function testDoesNothingWithoutAWhitelist(): void + { + $event = $this->event(['teamer@gmail.com']); + + $this->subscriber([])->onMessage($event); + + self::assertFalse($event->isRejected()); + self::assertSame(['teamer@gmail.com'], $this->addresses($event)); + } + + public function testPatternsAreAnchoredToTheWholeAddress(): void + { + $event = $this->event(['team@ep-reisen.de.attacker.com']); + + $this->subscriber(['.*@ep-reisen\.de'])->onMessage($event); + + self::assertTrue($event->isRejected()); + } + + public function testMatchingIsCaseInsensitive(): void + { + $event = $this->event(['Team@EP-Reisen.de']); + + $this->subscriber(['.*@ep-reisen\.de'])->onMessage($event); + + self::assertFalse($event->isRejected()); + self::assertSame(['Team@EP-Reisen.de'], $this->addresses($event)); + } + + public function testBlankPatternsAreIgnoredInsteadOfDroppingEveryMail(): void + { + $event = $this->event(['teamer@gmail.com']); + + $this->subscriber([' ', '', "\t"])->onMessage($event); + + self::assertFalse($event->isRejected()); + self::assertSame(['teamer@gmail.com'], $this->addresses($event)); + } + + public function testPatternsAreTrimmed(): void + { + $event = $this->event(['team@ep-reisen.de', 'teamer@gmail.com']); + + $this->subscriber([' .*@ep-reisen\\.de '])->onMessage($event); + + self::assertFalse($event->isRejected()); + self::assertSame(['team@ep-reisen.de'], $this->addresses($event)); + } + + public function testAnInvalidPatternFailsLoudlyOnConstruction(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid recipient whitelist pattern ".*@ep-reisen\\.de("'); + + $this->subscriber(['.*@ep-reisen\\.de(']); + } + + /** + * @param string[] $allowedRecipients + */ + private function subscriber(array $allowedRecipients): EmailRecipientWhitelistSubscriber + { + return new EmailRecipientWhitelistSubscriber($allowedRecipients, new NullLogger()); + } + + /** + * @param string[] $recipients + */ + private function event(array $recipients): MessageEvent + { + $email = (new Email()) + ->from('team@ep-reisen.de') + ->to(...$recipients) + ->subject('Honorarvertrag fällig') + ->text('...') + ; + + return new MessageEvent( + $email, + new Envelope(new Address('team@ep-reisen.de'), Address::createArray($recipients)), + 'main' + ); + } + + /** + * @return string[] + */ + private function addresses(MessageEvent $event): array + { + return array_map( + static fn (Address $address) => $address->getAddress(), + $event->getEnvelope()->getRecipients() + ); + } +}