From dc7f2baf2649df6a0f65a3348346d76d8617cfeb Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Fri, 11 Aug 2023 16:36:10 +0200 Subject: [PATCH] fix(dav): Rate limit calendar/subscription creation Signed-off-by: Christoph Wurst --- apps/dav/appinfo/v1/caldav.php | 2 + .../composer/composer/autoload_classmap.php | 1 + .../dav/composer/composer/autoload_static.php | 1 + apps/dav/lib/CalDAV/CalDavBackend.php | 21 +++ .../CalDAV/Security/RateLimitingPlugin.php | 111 ++++++++++++ apps/dav/lib/Server.php | 3 + .../Security/RateLimitingPluginTest.php | 166 ++++++++++++++++++ 7 files changed, 305 insertions(+) create mode 100644 apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php create mode 100644 apps/dav/tests/unit/CalDAV/Security/RateLimitingPluginTest.php diff --git a/apps/dav/appinfo/v1/caldav.php b/apps/dav/appinfo/v1/caldav.php index d8b4feb425204..e7924d9eedc50 100644 --- a/apps/dav/appinfo/v1/caldav.php +++ b/apps/dav/appinfo/v1/caldav.php @@ -29,6 +29,7 @@ use OC\KnownUser\KnownUserService; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\CalendarRoot; +use OCA\DAV\CalDAV\Security\RateLimitingPlugin; use OCA\DAV\Connector\LegacyDAVACL; use OCA\DAV\Connector\Sabre\Auth; use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin; @@ -116,6 +117,7 @@ $server->addPlugin(\OC::$server->query(\OCA\DAV\CalDAV\Schedule\IMipPlugin::class)); } $server->addPlugin(new ExceptionLoggerPlugin('caldav', $logger)); +$server->addPlugin(\OCP\Server::get(RateLimitingPlugin::class)); // And off we go! $server->exec(); diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index b7d7fd38a9a77..2a113bddbc347 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -99,6 +99,7 @@ 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/PropFilter.php', 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php', 'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => $baseDir . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php', + 'OCA\\DAV\\CalDAV\\Security\\RateLimitingPlugin' => $baseDir . '/../lib/CalDAV/Security/RateLimitingPlugin.php', 'OCA\\DAV\\CalDAV\\Sharing\\Backend' => $baseDir . '/../lib/CalDAV/Sharing/Backend.php', 'OCA\\DAV\\CalDAV\\Sharing\\Service' => $baseDir . '/../lib/CalDAV/Sharing/Service.php', 'OCA\\DAV\\CalDAV\\Status\\StatusService' => $baseDir . '/../lib/CalDAV/Status/StatusService.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 627213786a174..5042c9b3680db 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -114,6 +114,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/PropFilter.php', 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php', 'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php', + 'OCA\\DAV\\CalDAV\\Security\\RateLimitingPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/Security/RateLimitingPlugin.php', 'OCA\\DAV\\CalDAV\\Sharing\\Backend' => __DIR__ . '/..' . '/../lib/CalDAV/Sharing/Backend.php', 'OCA\\DAV\\CalDAV\\Sharing\\Service' => __DIR__ . '/..' . '/../lib/CalDAV/Sharing/Service.php', 'OCA\\DAV\\CalDAV\\Status\\StatusService' => __DIR__ . '/..' . '/../lib/CalDAV/Status/StatusService.php', diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index c694892089e95..23812ff7e1084 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -254,6 +254,27 @@ public function getCalendarsForUserCount($principalUri, $excludeBirthday = true) return $column; } + /** + * Return the number of subscriptions for a principal + */ + public function getSubscriptionsForUserCount(string $principalUri): int { + $principalUri = $this->convertPrincipal($principalUri, true); + $query = $this->db->getQueryBuilder(); + $query->select($query->func()->count('*')) + ->from('calendarsubscriptions'); + + if ($principalUri === '') { + $query->where($query->expr()->emptyString('principaluri')); + } else { + $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); + } + + $result = $query->executeQuery(); + $column = (int)$result->fetchOne(); + $result->closeCursor(); + return $column; + } + /** * @return array{id: int, deleted_at: int}[] */ diff --git a/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php b/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php new file mode 100644 index 0000000000000..8ee4c9cb4d3d6 --- /dev/null +++ b/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php @@ -0,0 +1,111 @@ + + * + * @author 2023 Christoph Wurst + * + * @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 . + */ + +namespace OCA\DAV\CalDAV\Security; + +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\Connector\Sabre\Exception\TooManyRequests; +use OCP\IAppConfig; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; +use Sabre\DAV; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\ServerPlugin; +use function count; +use function explode; + +class RateLimitingPlugin extends ServerPlugin { + + private Limiter $limiter; + private IUserManager $userManager; + private CalDavBackend $calDavBackend; + private IAppConfig $config; + private LoggerInterface $logger; + private ?string $userId; + + public function __construct(Limiter $limiter, + IUserManager $userManager, + CalDavBackend $calDavBackend, + LoggerInterface $logger, + IAppConfig $config, + ?string $userId) { + $this->limiter = $limiter; + $this->userManager = $userManager; + $this->calDavBackend = $calDavBackend; + $this->config = $config; + $this->logger = $logger; + $this->userId = $userId; + } + + public function initialize(DAV\Server $server): void { + $server->on('beforeBind', [$this, 'beforeBind'], 1); + } + + public function beforeBind(string $path): void { + if ($this->userId === null) { + // We only care about authenticated users here + return; + } + $user = $this->userManager->get($this->userId); + if ($user === null) { + // We only care about authenticated users here + return; + } + + $pathParts = explode('/', $path); + if (count($pathParts) === 3 && $pathParts[0] === 'calendars') { + // Path looks like calendars/username/calendarname so a new calendar or subscription is created + try { + $this->limiter->registerUserRequest( + 'caldav-create-calendar', + $this->config->getValueInt('dav', 'rateLimitCalendarCreation', 10), + $this->config->getValueInt('dav', 'rateLimitPeriodCalendarCreation', 3600), + $user + ); + } catch (RateLimitExceededException $e) { + throw new TooManyRequests('Too many calendars created', 0, $e); + } + + $calendarLimit = $this->config->getValueInt('dav', 'maximumCalendarsSubscriptions', 30); + if ($calendarLimit === -1) { + return; + } + $numCalendars = $this->calDavBackend->getCalendarsForUserCount('principals/users/' . $user->getUID()); + $numSubscriptions = $this->calDavBackend->getSubscriptionsForUserCount('principals/users/' . $user->getUID()); + + if (($numCalendars + $numSubscriptions) >= $calendarLimit) { + $this->logger->warning('Maximum number of calendars/subscriptions reached', [ + 'calendars' => $numCalendars, + 'subscription' => $numSubscriptions, + 'limit' => $calendarLimit, + ]); + throw new Forbidden('Calendar limit reached', 0); + } + } + } + +} diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 3197476437bb7..2bff8e7ebd642 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -39,6 +39,7 @@ use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\BulkUpload\BulkUploadPlugin; use OCA\DAV\CalDAV\BirthdayService; +use OCA\DAV\CalDAV\Security\RateLimitingPlugin; use OCA\DAV\CardDAV\HasPhotoPlugin; use OCA\DAV\CardDAV\ImageExportPlugin; use OCA\DAV\CardDAV\MultiGetExportPlugin; @@ -194,6 +195,8 @@ public function __construct(IRequest $request, string $baseUri) { \OC::$server->getConfig(), \OC::$server->getURLGenerator() )); + + $this->server->addPlugin(\OCP\Server::get(RateLimitingPlugin::class)); } // addressbook plugins diff --git a/apps/dav/tests/unit/CalDAV/Security/RateLimitingPluginTest.php b/apps/dav/tests/unit/CalDAV/Security/RateLimitingPluginTest.php new file mode 100644 index 0000000000000..1bb81ee4d3d8f --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/Security/RateLimitingPluginTest.php @@ -0,0 +1,166 @@ + + * + * @author 2023 Christoph Wurst + * + * @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 . + */ + +namespace OCA\DAV\Tests\unit\CalDAV\Security; + +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Security\RateLimitingPlugin; +use OCA\DAV\Connector\Sabre\Exception\TooManyRequests; +use OCP\IAppConfig; +use OCP\IUser; +use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Exception\Forbidden; +use Test\TestCase; + +class RateLimitingPluginTest extends TestCase { + + private Limiter|MockObject $limiter; + private CalDavBackend|MockObject $caldavBackend; + private IUserManager|MockObject $userManager; + private LoggerInterface|MockObject $logger; + private IAppConfig|MockObject $config; + private string $userId = 'user123'; + private RateLimitingPlugin $plugin; + + protected function setUp(): void { + parent::setUp(); + + $this->limiter = $this->createMock(Limiter::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->caldavBackend = $this->createMock(CalDavBackend::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->config = $this->createMock(IAppConfig::class); + $this->plugin = new RateLimitingPlugin( + $this->limiter, + $this->userManager, + $this->caldavBackend, + $this->logger, + $this->config, + $this->userId, + ); + } + + public function testNoUserObject(): void { + $this->limiter->expects(self::never()) + ->method('registerUserRequest'); + + $this->plugin->beforeBind('calendars/foo/cal'); + } + + public function testUnrelated(): void { + $user = $this->createMock(IUser::class); + $this->userManager->expects(self::once()) + ->method('get') + ->with($this->userId) + ->willReturn($user); + $this->limiter->expects(self::never()) + ->method('registerUserRequest'); + + $this->plugin->beforeBind('foo/bar'); + } + + public function testRegisterCalendarCreation(): void { + $user = $this->createMock(IUser::class); + $this->userManager->expects(self::once()) + ->method('get') + ->with($this->userId) + ->willReturn($user); + $this->config + ->method('getValueInt') + ->with('dav') + ->willReturnArgument(2); + $this->limiter->expects(self::once()) + ->method('registerUserRequest') + ->with( + 'caldav-create-calendar', + 10, + 3600, + $user, + ); + + $this->plugin->beforeBind('calendars/foo/cal'); + } + + public function testCalendarCreationRateLimitExceeded(): void { + $user = $this->createMock(IUser::class); + $this->userManager->expects(self::once()) + ->method('get') + ->with($this->userId) + ->willReturn($user); + $this->config + ->method('getValueInt') + ->with('dav') + ->willReturnArgument(2); + $this->limiter->expects(self::once()) + ->method('registerUserRequest') + ->with( + 'caldav-create-calendar', + 10, + 3600, + $user, + ) + ->willThrowException(new RateLimitExceededException()); + $this->expectException(TooManyRequests::class); + + $this->plugin->beforeBind('calendars/foo/cal'); + } + + public function testCalendarLimitReached(): void { + $user = $this->createMock(IUser::class); + $this->userManager->expects(self::once()) + ->method('get') + ->with($this->userId) + ->willReturn($user); + $user->method('getUID')->willReturn('user123'); + $this->config + ->method('getValueInt') + ->with('dav') + ->willReturnArgument(2); + $this->limiter->expects(self::once()) + ->method('registerUserRequest') + ->with( + 'caldav-create-calendar', + 10, + 3600, + $user, + ); + $this->caldavBackend->expects(self::once()) + ->method('getCalendarsForUserCount') + ->with('principals/users/user123') + ->willReturn(27); + $this->caldavBackend->expects(self::once()) + ->method('getSubscriptionsForUserCount') + ->with('principals/users/user123') + ->willReturn(3); + $this->expectException(Forbidden::class); + + $this->plugin->beforeBind('calendars/foo/cal'); + } + +}