]> source.dussan.org Git - nextcloud-server.git/commitdiff
fix(dav): Rate limit calendar/subscription creation 43732/head
authorChristoph Wurst <christoph@winzerhof-wurst.at>
Fri, 11 Aug 2023 14:36:10 +0000 (16:36 +0200)
committerChristoph Wurst <ChristophWurst@users.noreply.github.com>
Fri, 23 Feb 2024 07:52:59 +0000 (08:52 +0100)
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
apps/dav/appinfo/v1/caldav.php
apps/dav/composer/composer/autoload_classmap.php
apps/dav/composer/composer/autoload_static.php
apps/dav/lib/CalDAV/CalDavBackend.php
apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php [new file with mode: 0644]
apps/dav/lib/Server.php
apps/dav/tests/unit/CalDAV/Security/RateLimitingPluginTest.php [new file with mode: 0644]

index d8b4feb425204746ab05272649ef796f3077b633..e7924d9eedc50f34eb639c9162821b35b8ed6f46 100644 (file)
@@ -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 @@ if ($sendInvitations) {
        $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();
index b7d7fd38a9a774f3d5c51352125777ace9ebb6f2..2a113bddbc347008ea85163c8aa6716ee096bcc0 100644 (file)
@@ -99,6 +99,7 @@ return array(
     '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',
index 627213786a174d01e2855db84f1b45bcea868209..5042c9b3680dbc8c2261a335e7b8524acad3c3a9 100644 (file)
@@ -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',
index c694892089e9564d3dd56e38f46bd16caefe7697..23812ff7e1084ae8604a7dea70b3902c8542515f 100644 (file)
@@ -254,6 +254,27 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
                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 (file)
index 0000000..8ee4c9c
--- /dev/null
@@ -0,0 +1,111 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2023 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\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);
+                       }
+               }
+       }
+
+}
index 3197476437bb7407b587e12ee5d5d09fad57edc6..2bff8e7ebd642e23f366722b003a25a6976b6bec 100644 (file)
@@ -39,6 +39,7 @@ namespace OCA\DAV;
 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 @@ class Server {
                                \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 (file)
index 0000000..1bb81ee
--- /dev/null
@@ -0,0 +1,166 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2023 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\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');
+       }
+
+}