Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>tags/v29.0.0beta1
@@ -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(); |
@@ -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', |
@@ -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', |
@@ -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}[] | |||
*/ |
@@ -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); | |||
} | |||
} | |||
} | |||
} |
@@ -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 |
@@ -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'); | |||
} | |||
} |