Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@
'OCA\\DAV\\DAV\\GroupPrincipalBackend' => $baseDir . '/../lib/DAV/GroupPrincipalBackend.php',
'OCA\\DAV\\DAV\\PublicAuth' => $baseDir . '/../lib/DAV/PublicAuth.php',
'OCA\\DAV\\DAV\\RemoteUserPrincipalBackend' => $baseDir . '/../lib/DAV/RemoteUserPrincipalBackend.php',
'OCA\\DAV\\DAV\\Security\\RateLimiting' => $baseDir . '/../lib/DAV/Security/RateLimiting.php',
'OCA\\DAV\\DAV\\Sharing\\Backend' => $baseDir . '/../lib/DAV/Sharing/Backend.php',
'OCA\\DAV\\DAV\\Sharing\\IShareable' => $baseDir . '/../lib/DAV/Sharing/IShareable.php',
'OCA\\DAV\\DAV\\Sharing\\Plugin' => $baseDir . '/../lib/DAV/Sharing/Plugin.php',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\DAV\\GroupPrincipalBackend' => __DIR__ . '/..' . '/../lib/DAV/GroupPrincipalBackend.php',
'OCA\\DAV\\DAV\\PublicAuth' => __DIR__ . '/..' . '/../lib/DAV/PublicAuth.php',
'OCA\\DAV\\DAV\\RemoteUserPrincipalBackend' => __DIR__ . '/..' . '/../lib/DAV/RemoteUserPrincipalBackend.php',
'OCA\\DAV\\DAV\\Security\\RateLimiting' => __DIR__ . '/..' . '/../lib/DAV/Security/RateLimiting.php',
'OCA\\DAV\\DAV\\Sharing\\Backend' => __DIR__ . '/..' . '/../lib/DAV/Sharing/Backend.php',
'OCA\\DAV\\DAV\\Sharing\\IShareable' => __DIR__ . '/..' . '/../lib/DAV/Sharing/IShareable.php',
'OCA\\DAV\\DAV\\Sharing\\Plugin' => __DIR__ . '/..' . '/../lib/DAV/Sharing/Plugin.php',
Expand Down
46 changes: 46 additions & 0 deletions apps/dav/lib/DAV/Security/RateLimiting.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\DAV\Security;

use OCA\DAV\Connector\Sabre\Exception\TooManyRequests;
use OCP\IAppConfig;
use OCP\IUserSession;
use OCP\Security\RateLimiting\ILimiter;
use OCP\Security\RateLimiting\IRateLimitExceededException;

class RateLimiting {

public function __construct(
private readonly IUserSession $userSession,
private readonly IAppConfig $config,
private readonly ILimiter $limiter,
) {
}

/**
* @throws TooManyRequests
*/
public function check(): void {
$user = $this->userSession->getUser();
if ($user === null) {
return;
}

$identifier = 'share-addressbook-or-calendar';
$userLimit = $this->config->getValueInt('dav', 'rateLimitShareAddressbookOrCalendar', 20);
$userPeriod = $this->config->getValueInt('dav', 'rateLimitPeriodShareAddressbookOrCalendar', 3600);

try {
$this->limiter->registerUserRequest($identifier, $userLimit, $userPeriod, $user);
} catch (IRateLimitExceededException $e) {
throw new TooManyRequests('Too many addressbook or calendar share requests', 0, $e);
}
}
}
30 changes: 23 additions & 7 deletions apps/dav/lib/DAV/Sharing/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\CalendarHome;
use OCA\DAV\Connector\Sabre\Auth;
use OCA\DAV\DAV\Security\RateLimiting;
use OCA\DAV\DAV\Sharing\Xml\Invite;
use OCA\DAV\DAV\Sharing\Xml\ShareRequest;
use OCP\AppFramework\Http;
use OCP\IConfig;
use OCP\IRequest;
use Sabre\DAV\Exception\BadRequest;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\ICollection;
use Sabre\DAV\INode;
Expand All @@ -28,17 +30,11 @@ class Plugin extends ServerPlugin {
public const NS_OWNCLOUD = 'http://owncloud.org/ns';
public const NS_NEXTCLOUD = 'http://nextcloud.com/ns';

/**
* Plugin constructor.
*
* @param Auth $auth
* @param IRequest $request
* @param IConfig $config
*/
public function __construct(
private Auth $auth,
private IRequest $request,
private IConfig $config,
private RateLimiting $rateLimiting,
) {
}

Expand Down Expand Up @@ -136,6 +132,9 @@ public function httpPost(RequestInterface $request, ResponseInterface $response)
// calendar.
case '{' . self::NS_OWNCLOUD . '}share':

$this->rateLimiting->check();
$this->validateShareRequest($message);

// We can only deal with IShareableCalendar objects
if (!$node instanceof IShareable) {
return;
Expand Down Expand Up @@ -170,6 +169,23 @@ public function httpPost(RequestInterface $request, ResponseInterface $response)
}
}

private function validateShareRequest($shareRequest): void {
if (!$shareRequest instanceof ShareRequest) {
// @FIXME: Replace switch-case in httpPost with instanceof ShareRequest
throw new BadRequest('The given request is not valid');
}

$elements = (count($shareRequest->set) + count($shareRequest->remove));

if ($elements === 0) {
throw new BadRequest(ShareRequest::ELEMENT_SHARE . ' needs at least one set or remove element');
}

if ($elements > 10) {
throw new BadRequest(ShareRequest::ELEMENT_SHARE . ' is limited to 10 set or remove elements');
}
}

private function preloadCollection(PropFind $propFind, ICollection $collection): void {
if (!$collection instanceof CalendarHome || $propFind->getDepth() !== 1) {
return;
Expand Down
6 changes: 6 additions & 0 deletions apps/dav/lib/DAV/Sharing/Xml/ShareRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use Sabre\Xml\XmlDeserializable;

class ShareRequest implements XmlDeserializable {
public const ELEMENT_SHARE = '{' . Plugin::NS_OWNCLOUD . '}share';

/**
* Constructor
*
Expand All @@ -33,6 +35,10 @@ public static function xmlDeserialize(Reader $reader) {
$set = [];
$remove = [];

if ($elements === null) {
return new self($set, $remove);
}

foreach ($elements as $elem) {
switch ($elem['name']) {

Expand Down
5 changes: 3 additions & 2 deletions apps/dav/lib/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
use OCA\DAV\Connector\Sabre\ZipFolderPlugin;
use OCA\DAV\DAV\CustomPropertiesBackend;
use OCA\DAV\DAV\PublicAuth;
use OCA\DAV\DAV\Security\RateLimiting;
use OCA\DAV\DAV\ViewOnlyPlugin;
use OCA\DAV\Db\PropertyMapper;
use OCA\DAV\Events\SabrePluginAddEvent;
Expand Down Expand Up @@ -198,7 +199,7 @@ public function __construct(

// calendar plugins
if ($this->requestIsForSubtree(['calendars', 'public-calendars', 'system-calendars', 'principals'])) {
$this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class)));
$this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class), \OCP\Server::get(RateLimiting::class)));
$this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin());
$this->server->addPlugin(new ICSExportPlugin(\OCP\Server::get(IConfig::class), $logger));
$this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OCP\Server::get(IConfig::class), \OCP\Server::get(LoggerInterface::class), \OCP\Server::get(DefaultCalendarValidator::class)));
Expand All @@ -221,7 +222,7 @@ public function __construct(

// addressbook plugins
if ($this->requestIsForSubtree(['addressbooks', 'principals'])) {
$this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class)));
$this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class), \OCP\Server::get(RateLimiting::class)));
$this->server->addPlugin(new \OCA\DAV\CardDAV\Plugin());
$this->server->addPlugin(new VCFExportPlugin());
$this->server->addPlugin(new MultiGetExportPlugin());
Expand Down
62 changes: 0 additions & 62 deletions apps/dav/tests/unit/CardDAV/Sharing/PluginTest.php

This file was deleted.

99 changes: 99 additions & 0 deletions apps/dav/tests/unit/DAV/Security/RateLimitingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\Tests\unit\DAV\Security;

use OCA\DAV\Connector\Sabre\Exception\TooManyRequests;
use OCA\DAV\DAV\Security\RateLimiting;
use OCP\IAppConfig;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Security\RateLimiting\ILimiter;
use OCP\Security\RateLimiting\IRateLimitExceededException;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class RateLimitingTest extends TestCase {
private IUserSession $userSession;
private IAppConfig&MockObject $config;
private ILimiter&MockObject $limiter;
private RateLimiting $rateLimiting;
private string $userId = 'user123';

protected function setUp(): void {
parent::setUp();

$this->userSession = $this->createMock(IUserSession::class);
$this->config = $this->createMock(IAppConfig::class);
$this->limiter = $this->createMock(ILimiter::class);

$this->rateLimiting = new RateLimiting(
$this->userSession,
$this->config,
$this->limiter,
);
}

public function testNoUserObject(): void {
$this->userSession->expects($this->once())
->method('getUser')
->willReturn(null);
$this->limiter->expects($this->never())
->method('registerUserRequest');

$this->rateLimiting->check();
}

public function testRegisterShareRequest(): void {
$user = $this->createMock(IUser::class);
$this->userSession->expects($this->once())
->method('getUser')
->willReturn($user);
$this->config->method('getValueInt')
->willReturnCallback(static function (string $app, string $key, int $default): int {
return match ($key) {
'rateLimitShareAddressbookOrCalendar' => 7,
'rateLimitPeriodShareAddressbookOrCalendar' => 600,
default => $default,
};
});
$this->limiter->expects($this->once())
->method('registerUserRequest')
->with(
'share-addressbook-or-calendar',
7,
600,
$user,
);

$this->rateLimiting->check();
}

public function testShareRequestRateLimitExceeded(): void {
$user = $this->createMock(IUser::class);
$this->userSession->expects($this->once())
->method('getUser')
->willReturn($user);
$this->config->method('getValueInt')
->willReturnArgument(2);
$this->limiter->expects($this->once())
->method('registerUserRequest')
->with(
'share-addressbook-or-calendar',
20,
3600,
$user,
)
->willThrowException($this->createMock(IRateLimitExceededException::class));

$this->expectException(TooManyRequests::class);

$this->rateLimiting->check();
}
}
Loading
Loading