feat: email recipient whitelist
This commit is contained in:
1 parent
4cab57d136
commit
7cf39850e6
4 files changed
+308
No files matched your search
@@ -70,6 +70,14 @@ APP_BPN_CRM_ID_TEAMER=1070
|
||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||
|
||||
# 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=
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Mailer\Event\MessageEvent;
|
||||
use Symfony\Component\Mime\Address;
|
||||
|
||||
/**
|
||||
* Keeps mail away from real people on deployments that are not production.
|
||||
*
|
||||
* Staging runs the real transports against a copy of the production data, so every recipient
|
||||
* in that database is a real address. With a whitelist configured, an envelope is reduced to
|
||||
* the recipients it allows and a mail left without any recipient is not sent at all.
|
||||
*
|
||||
* An empty whitelist means "do nothing", never "drop everything": production runs with the
|
||||
* environment variable unset and is therefore unaffected by this class. This is a deployment
|
||||
* guard - no business rule decides who may be mailed here.
|
||||
*
|
||||
* Symfony's own framework.mailer.envelope handling cannot do this: it redirects everything to
|
||||
* a catch-all address, and its allowed_recipients only adds the real recipient back on top.
|
||||
*/
|
||||
class EmailRecipientWhitelistSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private readonly array $allowedRecipients;
|
||||
|
||||
/**
|
||||
* @param string[] $allowedRecipients regular expressions matched against the full address
|
||||
*
|
||||
* @throws \InvalidArgumentException on a pattern that is not a valid regular expression;
|
||||
* the service is lazy, so this surfaces on the first mail
|
||||
*/
|
||||
public function __construct(
|
||||
array $allowedRecipients,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
// A blank entry - a stray space in a .env.local, a deploy template emitting an empty
|
||||
// value - would otherwise make the whitelist non-empty while matching no address at
|
||||
// all, which silently drops every mail. Dropped here, so "empty" stays "do nothing".
|
||||
$patterns = array_values(array_filter(array_map(trim(...), $allowedRecipients), static fn (string $pattern) => '' !== $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\EventListener;
|
||||
|
||||
use App\EventListener\EmailRecipientWhitelistSubscriber;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Mailer\Envelope;
|
||||
use Symfony\Component\Mailer\Event\MessageEvent;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
|
||||
/**
|
||||
* The guard that keeps staging from mailing real teamers: everything not whitelisted has to
|
||||
* leave the envelope, and a mail with nothing left must not be sent at all.
|
||||
*/
|
||||
class EmailRecipientWhitelistSubscriberTest extends TestCase
|
||||
{
|
||||
public function testKeepsEnvelopeUntouchedWhenEveryRecipientIsWhitelisted(): void
|
||||
{
|
||||
$event = $this->event(['[email protected]', '[email protected]']);
|
||||
|
||||
$this->subscriber(['.*@ep-reisen\.de', 'qa@example\.com'])->onMessage($event);
|
||||
|
||||
self::assertFalse($event->isRejected());
|
||||
self::assertSame(
|
||||
['[email protected]', '[email protected]'],
|
||||
$this->addresses($event)
|
||||
);
|
||||
}
|
||||
|
||||
public function testStripsTheRecipientsThatAreNotWhitelisted(): void
|
||||
{
|
||||
$event = $this->event(['[email protected]', '[email protected]', '[email protected]']);
|
||||
|
||||
$this->subscriber(['.*@ep-reisen\.de'])->onMessage($event);
|
||||
|
||||
self::assertFalse($event->isRejected());
|
||||
self::assertSame(['[email protected]'], $this->addresses($event));
|
||||
}
|
||||
|
||||
public function testRejectsTheMailWhenNoRecipientIsWhitelisted(): void
|
||||
{
|
||||
$event = $this->event(['[email protected]', '[email protected]']);
|
||||
|
||||
$this->subscriber(['.*@ep-reisen\.de'])->onMessage($event);
|
||||
|
||||
self::assertTrue($event->isRejected());
|
||||
}
|
||||
|
||||
public function testDoesNothingWithoutAWhitelist(): void
|
||||
{
|
||||
$event = $this->event(['[email protected]']);
|
||||
|
||||
$this->subscriber([])->onMessage($event);
|
||||
|
||||
self::assertFalse($event->isRejected());
|
||||
self::assertSame(['[email protected]'], $this->addresses($event));
|
||||
}
|
||||
|
||||
public function testPatternsAreAnchoredToTheWholeAddress(): void
|
||||
{
|
||||
$event = $this->event(['[email protected]']);
|
||||
|
||||
$this->subscriber(['.*@ep-reisen\.de'])->onMessage($event);
|
||||
|
||||
self::assertTrue($event->isRejected());
|
||||
}
|
||||
|
||||
public function testMatchingIsCaseInsensitive(): void
|
||||
{
|
||||
$event = $this->event(['[email protected]']);
|
||||
|
||||
$this->subscriber(['.*@ep-reisen\.de'])->onMessage($event);
|
||||
|
||||
self::assertFalse($event->isRejected());
|
||||
self::assertSame(['[email protected]'], $this->addresses($event));
|
||||
}
|
||||
|
||||
public function testBlankPatternsAreIgnoredInsteadOfDroppingEveryMail(): void
|
||||
{
|
||||
$event = $this->event(['[email protected]']);
|
||||
|
||||
$this->subscriber([' ', '', "\t"])->onMessage($event);
|
||||
|
||||
self::assertFalse($event->isRejected());
|
||||
self::assertSame(['[email protected]'], $this->addresses($event));
|
||||
}
|
||||
|
||||
public function testPatternsAreTrimmed(): void
|
||||
{
|
||||
$event = $this->event(['[email protected]', '[email protected]']);
|
||||
|
||||
$this->subscriber([' .*@ep-reisen\\.de '])->onMessage($event);
|
||||
|
||||
self::assertFalse($event->isRejected());
|
||||
self::assertSame(['[email protected]'], $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('[email protected]')
|
||||
->to(...$recipients)
|
||||
->subject('Honorarvertrag fällig')
|
||||
->text('...')
|
||||
;
|
||||
|
||||
return new MessageEvent(
|
||||
$email,
|
||||
new Envelope(new Address('[email protected]'), Address::createArray($recipients)),
|
||||
'main'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function addresses(MessageEvent $event): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (Address $address) => $address->getAddress(),
|
||||
$event->getEnvelope()->getRecipients()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user