diff options
author | Christoph Wurst <christoph@winzerhof-wurst.at> | 2021-12-03 15:00:18 +0300 |
---|---|---|
committer | Christoph Wurst <christoph@winzerhof-wurst.at> | 2022-03-21 19:11:54 +0300 |
commit | 9096248744e858975b30a7279323a793390afd6e (patch) | |
tree | 4ceffca46e6b0fa59c607cd6f4c5fd0244ac65ef | |
parent | e181724033cd525fc9840594e545f1a2e3aa0f4b (diff) |
Add anti abuse detection
* Trigger alert for number of recipients of a single message
* Trigger alert for number of messages per time period (15m, 1h, 1d)
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
-rw-r--r-- | doc/admin.md | 24 | ||||
-rw-r--r-- | lib/AppInfo/Application.php | 3 | ||||
-rw-r--r-- | lib/Events/BeforeMessageSentEvent.php | 97 | ||||
-rw-r--r-- | lib/Listener/AntiAbuseListener.php | 72 | ||||
-rw-r--r-- | lib/Service/AntiAbuseService.php | 151 | ||||
-rw-r--r-- | lib/Service/MailTransmission.php | 5 | ||||
-rw-r--r-- | tests/Unit/Service/AntiAbuseServiceTest.php | 199 | ||||
-rw-r--r-- | tests/psalm-baseline.xml | 17 |
8 files changed, 566 insertions, 2 deletions
diff --git a/doc/admin.md b/doc/admin.md index 41a5622d8..90716eb41 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -44,6 +44,30 @@ Turn off TLS verfication for IMAP/SMTP. This happens globally for all accounts a 'app.mail.verify-tls-peer' => false ``` +### Anti-abuse alerts + +The app can write alerts to the logs when users send messages to a high number of recipients or sends a high number of messages for a short period of time. These events might indicate that the account is abused for sending spam messages. + +To enable anti-abuse alerts, you'll have to set a few configuration options [via occ](https://docs.nextcloud.com/server/stable/admin_manual/configuration_server/occ_command.html). + +```bash +# Turn alerts on +occ config:app:set mail abuse_detection --value=on +# Turn alerts off +occ config:app:set mail abuse_detection --value=off + +# Alert when 50 or more recipients are used for one single message +occ config:app:set mail abuse_number_of_recipients_per_message_threshold --value=50 + +# Alerts can be configured for three intervals: 15m, 1h and 1d +# Alert when more than 10 messages are sent in 15 minutes +occ config:app:set mail abuse_number_of_messages_per_15m --value=10 +# Alert when more than 30 messages are sent in one hour +occ config:app:set mail abuse_number_of_messages_per_1h --value=30 +# Alert when more than 100 messages are sent in one day +occ config:app:set mail abuse_number_of_messages_per_1d --value=100 +``` + ## Troubleshooting ### Logging diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3228834e5..8a1adbab7 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -34,6 +34,7 @@ use OCA\Mail\Contracts\ITrustedSenderService; use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Dashboard\ImportantMailWidget; use OCA\Mail\Dashboard\UnreadMailWidget; +use OCA\Mail\Events\BeforeMessageSentEvent; use OCA\Mail\Events\DraftSavedEvent; use OCA\Mail\Events\MailboxesSynchronizedEvent; use OCA\Mail\Events\SynchronizationEvent; @@ -45,6 +46,7 @@ use OCA\Mail\HordeTranslationHandler; use OCA\Mail\Http\Middleware\ErrorMiddleware; use OCA\Mail\Http\Middleware\ProvisioningMiddleware; use OCA\Mail\Listener\AddressCollectionListener; +use OCA\Mail\Listener\AntiAbuseListener; use OCA\Mail\Listener\HamReportListener; use OCA\Mail\Listener\SpamReportListener; use OCA\Mail\Listener\DeleteDraftListener; @@ -100,6 +102,7 @@ class Application extends App implements IBootstrap { $context->registerServiceAlias(ITrustedSenderService::class, TrustedSenderService::class); $context->registerServiceAlias(IUserPreferences::class, UserPreferenceService::class); + $context->registerEventListener(BeforeMessageSentEvent::class, AntiAbuseListener::class); $context->registerEventListener(DraftSavedEvent::class, DeleteDraftListener::class); $context->registerEventListener(MailboxesSynchronizedEvent::class, MailboxesSynchronizedSpecialMailboxesUpdater::class); $context->registerEventListener(MessageFlaggedEvent::class, MessageCacheUpdaterListener::class); diff --git a/lib/Events/BeforeMessageSentEvent.php b/lib/Events/BeforeMessageSentEvent.php new file mode 100644 index 000000000..b7164e14f --- /dev/null +++ b/lib/Events/BeforeMessageSentEvent.php @@ -0,0 +1,97 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Events; + +use Horde_Mime_Mail; +use OCA\Mail\Account; +use OCA\Mail\Db\Message; +use OCA\Mail\Model\IMessage; +use OCA\Mail\Model\NewMessageData; +use OCA\Mail\Model\RepliedMessageData; +use OCP\EventDispatcher\Event; + +/** + * @psalm-immutable + */ +class BeforeMessageSentEvent extends Event { + + /** @var Account */ + private $account; + + /** @var NewMessageData */ + private $newMessageData; + + /** @var null|RepliedMessageData */ + private $repliedMessageData; + + /** @var Message|null */ + private $draft; + + /** @var IMessage */ + private $message; + + /** @var Horde_Mime_Mail */ + private $mail; + + public function __construct(Account $account, + NewMessageData $newMessageData, + ?RepliedMessageData $repliedMessageData, + ?Message $draft, + IMessage $message, + Horde_Mime_Mail $mail) { + parent::__construct(); + $this->account = $account; + $this->newMessageData = $newMessageData; + $this->repliedMessageData = $repliedMessageData; + $this->draft = $draft; + $this->message = $message; + $this->mail = $mail; + } + + public function getAccount(): Account { + return $this->account; + } + + public function getNewMessageData(): NewMessageData { + return $this->newMessageData; + } + + public function getRepliedMessageData(): ?RepliedMessageData { + return $this->repliedMessageData; + } + + public function getDraft(): ?Message { + return $this->draft; + } + + public function getMessage(): IMessage { + return $this->message; + } + + public function getMail(): Horde_Mime_Mail { + return $this->mail; + } +} diff --git a/lib/Listener/AntiAbuseListener.php b/lib/Listener/AntiAbuseListener.php new file mode 100644 index 000000000..0f9aac5cf --- /dev/null +++ b/lib/Listener/AntiAbuseListener.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Listener; + +use OCA\Mail\Events\BeforeMessageSentEvent; +use OCA\Mail\Service\AntiAbuseService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class AntiAbuseListener implements IEventListener { + + /** @var IUserManager */ + private $userManager; + + /** @var AntiAbuseService */ + private $service; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(IUserManager $userManager, + AntiAbuseService $service, + LoggerInterface $logger) { + $this->service = $service; + $this->userManager = $userManager; + $this->logger = $logger; + } + + public function handle(Event $event): void { + if (!($event instanceof BeforeMessageSentEvent)) { + return; + } + + $user = $this->userManager->get($event->getAccount()->getUserId()); + if ($user === null) { + $this->logger->error('User {user} for mail account {id} does not exist', [ + 'user' => $event->getAccount()->getUserId(), + 'id' => $event->getAccount()->getId(), + ]); + } + + $this->service->onBeforeMessageSent( + $user, + $event->getNewMessageData(), + ); + } +} diff --git a/lib/Service/AntiAbuseService.php b/lib/Service/AntiAbuseService.php new file mode 100644 index 000000000..ae4c09e19 --- /dev/null +++ b/lib/Service/AntiAbuseService.php @@ -0,0 +1,151 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Service; + +use OCA\Mail\AppInfo\Application; +use OCA\Mail\Model\NewMessageData; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\IMemcache; +use OCP\IUser; +use Psr\Log\LoggerInterface; +use function implode; + +class AntiAbuseService { + + /** @var IConfig */ + private $config; + + /** @var ICacheFactory */ + private $cacheFactory; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(IConfig $config, + ICacheFactory $cacheFactory, + ITimeFactory $timeFactory, + LoggerInterface $logger) { + $this->config = $config; + $this->cacheFactory = $cacheFactory; + $this->timeFactory = $timeFactory; + $this->logger = $logger; + } + + public function onBeforeMessageSent(IUser $user, + NewMessageData $messageData): void { + $abuseDetection = $this->config->getAppValue( + Application::APP_ID, + 'abuse_detection', + 'off' + ); + if ($abuseDetection !== 'on') { + $this->logger->debug('Anti abuse detection is off'); + return; + } + + $this->checkNumberOfRecipients($user, $messageData); + $this->checkRateLimits($user, $messageData); + } + + private function checkNumberOfRecipients(IUser $user, + NewMessageData $messageData): void { + $numberOfRecipientsThreshold = (int)$this->config->getAppValue( + Application::APP_ID, + 'abuse_number_of_recipients_per_message_threshold', + '0', + ); + if ($numberOfRecipientsThreshold <= 1) { + return; + } + + $actualNumberOfRecipients = count($messageData->getTo()) + + count($messageData->getCc()) + + count($messageData->getBcc()); + + if ($actualNumberOfRecipients >= $numberOfRecipientsThreshold) { + $this->logger->alert('User {user} sends to a suspicious number of recipients. {expected} are allowed. {actual} are used', [ + 'user' => $user->getUID(), + 'expected' => $numberOfRecipientsThreshold, + 'actual' => $actualNumberOfRecipients, + ]); + } + } + + private function checkRateLimits(IUser $user, + NewMessageData $messageData): void { + if (!$this->cacheFactory->isAvailable()) { + // No cache, no rate limits + return; + } + $cache = $this->cacheFactory->createDistributed('mail_anti_abuse'); + if (!($cache instanceof IMemcache)) { + // This integration only works with caches that support inc and dec + return; + } + + $this->checkRateLimitsForPeriod($user, $messageData, $cache, '15m', 15 * 60); + $this->checkRateLimitsForPeriod($user, $messageData, $cache, '1h', 60 * 60); + $this->checkRateLimitsForPeriod($user, $messageData, $cache, '1d', 24 * 60 * 60); + } + + private function checkRateLimitsForPeriod(IUser $user, + NewMessageData $messageData, + IMemcache $cache, + string $id, + int $period): void { + $maxNumberOfMessages = (int)$this->config->getAppValue( + Application::APP_ID, + 'abuse_number_of_messages_per_' . $id, + '0', + ); + if ($maxNumberOfMessages === 0) { + // No limit set + return; + } + + $now = $this->timeFactory->getTime(); + + // Build blocks of periods per period size + $periodStart = ((int)($now / $period)) * $period; + $cacheKey = implode('_', ['counter', $id, $periodStart]); + $cache->add($cacheKey, 0); + $counter = $cache->inc($cacheKey, count($messageData->getTo()) + count($messageData->getCc()) + count($messageData->getBcc())); + + if ($counter >= $maxNumberOfMessages) { + $this->logger->alert('User {user} sends a supcious number of messages within {period}. {expected} are allowed. {actual} have been sent', [ + 'user' => $user->getUID(), + 'period' => $id, + 'expected' => $maxNumberOfMessages, + 'actual' => $counter, + ]); + } + } +} diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index e30531ab1..23fe8bdda 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -49,6 +49,7 @@ use OCA\Mail\Db\Alias; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Db\Message; +use OCA\Mail\Events\BeforeMessageSentEvent; use OCA\Mail\Events\DraftSavedEvent; use OCA\Mail\Events\MessageSentEvent; use OCA\Mail\Events\SaveDraftEvent; @@ -190,6 +191,10 @@ class MailTransmission implements IMailTransmission { $mail->addMimePart($attachment); } + $this->eventDispatcher->dispatchTyped( + new BeforeMessageSentEvent($account, $messageData, $replyData, $draft, $message, $mail) + ); + // Send the message try { $mail->send($transport, false, false); diff --git a/tests/Unit/Service/AntiAbuseServiceTest.php b/tests/Unit/Service/AntiAbuseServiceTest.php new file mode 100644 index 000000000..ffbd22f4f --- /dev/null +++ b/tests/Unit/Service/AntiAbuseServiceTest.php @@ -0,0 +1,199 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Tests\Unit\Service; + +use ChristophWurst\Nextcloud\Testing\ServiceMockObject; +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Account; +use OCA\Mail\Address; +use OCA\Mail\AddressList; +use OCA\Mail\Model\NewMessageData; +use OCA\Mail\Service\AntiAbuseService; +use OCP\IMemcache; +use OCP\IUser; +use function array_map; +use function range; + +class AntiAbuseServiceTest extends TestCase { + + /** @var AntiAbuseService */ + private $service; + + /** @var ServiceMockObject */ + private $serviceMock; + + protected function setUp(): void { + parent::setUp(); + + $this->serviceMock = $this->createServiceMock(AntiAbuseService::class); + $this->service = $this->serviceMock->getService(); + } + + public function testThresholdDisabled(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user123'); + $account = $this->createMock(Account::class); + $messageData = new NewMessageData( + $account, + new AddressList([]), + new AddressList([]), + new AddressList([]), + 'subject', + 'henlo', + ); + $this->serviceMock->getParameter('config') + ->expects(self::once()) + ->method('getAppValue') + ->withConsecutive( + ['mail', 'abuse_detection', 'off'], + )->willReturnOnConsecutiveCalls( + 'off', + ); + $this->serviceMock->getParameter('logger') + ->expects(self::never()) + ->method('alert'); + + $this->service->onBeforeMessageSent( + $user, + $messageData, + ); + } + + public function testThresholdReached(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user123'); + $account = $this->createMock(Account::class); + $messageData = new NewMessageData( + $account, + new AddressList(array_map(static function (int $i) { + return Address::fromRaw( + "user$i@domain.tld", + "user$i@domain.tld", + ); + }, range(1, 50))), + new AddressList(array_map(static function (int $i) { + return Address::fromRaw( + "user$i@domain.tld", + "user$i@domain.tld", + ); + }, range(51, 60))), + new AddressList(array_map(static function (int $i) { + return Address::fromRaw( + "user$i@domain.tld", + "user$i@domain.tld", + ); + }, range(51, 70))), + 'subject', + 'henlo', + ); + $this->serviceMock->getParameter('config') + ->method('getAppValue') + ->withConsecutive( + ['mail', 'abuse_detection', 'off'], + ['mail', 'abuse_number_of_recipients_per_message_threshold', '0'], + )->willReturnOnConsecutiveCalls( + 'on', + '50', + ); + $this->serviceMock->getParameter('logger') + ->expects(self::once()) + ->method('alert') + ->with(self::anything(), [ + 'user' => 'user123', + 'expected' => 50, + 'actual' => 80, + ]); + + $this->service->onBeforeMessageSent( + $user, + $messageData, + ); + } + + public function test15mThreshold(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user123'); + $account = $this->createMock(Account::class); + $messageData = new NewMessageData( + $account, + new AddressList([ + Address::fromRaw( + "user@domain.tld", + "user@domain.tld", + ) + ]), + new AddressList([]), + new AddressList([]), + 'subject', + 'henlo', + ); + $this->serviceMock->getParameter('config') + ->method('getAppValue') + ->withConsecutive( + ['mail', 'abuse_detection', 'off'], + ['mail', 'abuse_number_of_recipients_per_message_threshold', '0'], + ['mail', 'abuse_number_of_messages_per_15m', '0'] + )->willReturnOnConsecutiveCalls( + 'on', + '0', + '5', + ); + $this->serviceMock->getParameter('cacheFactory') + ->expects(self::once()) + ->method('isAvailable') + ->willReturn(true); + $cache = $this->createMock(IMemcache::class); + $this->serviceMock->getParameter('cacheFactory') + ->expects(self::once()) + ->method('createDistributed') + ->willReturn($cache); + $this->serviceMock->getParameter('timeFactory') + ->expects(self::once()) + ->method('getTime') + ->willReturn(123456); + $cache->expects(self::once()) + ->method('add') + ->with('counter_15m_123300', 0); + $cache->expects(self::once()) + ->method('inc') + ->with('counter_15m_123300') + ->willReturn(5); + $this->serviceMock->getParameter('logger') + ->expects(self::once()) + ->method('alert') + ->with(self::anything(), [ + 'user' => 'user123', + 'period' => '15m', + 'expected' => 5, + 'actual' => 5, + ]); + + $this->service->onBeforeMessageSent( + $user, + $messageData, + ); + } +} diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 16f0951d4..e2909dab4 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -1,7 +1,8 @@ <?xml version="1.0" encoding="UTF-8"?> <files psalm-version="4.x-dev@"> <file src="lib/AppInfo/Application.php"> - <MissingDependency occurrences="14"> + <MissingDependency occurrences="15"> + <code>BeforeMessageSentEvent</code> <code>DraftSavedEvent</code> <code>MailboxesSynchronizedEvent</code> <code>MessageDeletedEvent</code> @@ -161,6 +162,11 @@ <code>Event</code> </MissingDependency> </file> + <file src="lib/Events/BeforeMessageSentEvent.php"> + <MissingDependency occurrences="1"> + <code>Event</code> + </MissingDependency> + </file> <file src="lib/Events/DraftSavedEvent.php"> <MissingDependency occurrences="1"> <code>Event</code> @@ -218,6 +224,12 @@ <code>MessageSentEvent</code> </MissingDependency> </file> + <file src="lib/Listener/AntiAbuseListener.php"> + <MissingDependency occurrences="2"> + <code>AntiAbuseListener</code> + <code>BeforeMessageSentEvent</code> + </MissingDependency> + </file> <file src="lib/Listener/DashboardPanelListener.php"> <MissingDependency occurrences="2"> <code>DashboardPanelListener</code> @@ -313,7 +325,8 @@ </MissingDependency> </file> <file src="lib/Service/MailTransmission.php"> - <MissingDependency occurrences="6"> + <MissingDependency occurrences="7"> + <code>BeforeMessageSentEvent</code> <code>DraftSavedEvent</code> <code>DraftSavedEvent</code> <code>MessageSentEvent</code> |