Skip to content

Commit 1d80b92

Browse files
ChristophWurstbackportbot[bot]
authored andcommitted
fix(dav): Rate limit calendar/subscription creation
Signed-off-by: Christoph Wurst <[email protected]>
1 parent e6507f4 commit 1d80b92

File tree

7 files changed

+305
-0
lines changed

7 files changed

+305
-0
lines changed

apps/dav/appinfo/v1/caldav.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
// Backends
2929
use OC\KnownUser\KnownUserService;
3030
use OCA\DAV\CalDAV\CalDavBackend;
31+
use OCA\DAV\CalDAV\Security\RateLimitingPlugin;
3132
use OCA\DAV\Connector\LegacyDAVACL;
3233
use OCA\DAV\CalDAV\CalendarRoot;
3334
use OCA\DAV\Connector\Sabre\Auth;
@@ -116,6 +117,7 @@
116117
$server->addPlugin(\OC::$server->query(\OCA\DAV\CalDAV\Schedule\IMipPlugin::class));
117118
}
118119
$server->addPlugin(new ExceptionLoggerPlugin('caldav', $logger));
120+
$server->addPlugin(\OCP\Server::get(RateLimitingPlugin::class));
119121

120122
// And off we go!
121123
$server->exec();

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/PropFilter.php',
9494
'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php',
9595
'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => $baseDir . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php',
96+
'OCA\\DAV\\CalDAV\\Security\\RateLimitingPlugin' => $baseDir . '/../lib/CalDAV/Security/RateLimitingPlugin.php',
9697
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => $baseDir . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php',
9798
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObjectsCollection' => $baseDir . '/../lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php',
9899
'OCA\\DAV\\CalDAV\\Trashbin\\Plugin' => $baseDir . '/../lib/CalDAV/Trashbin/Plugin.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class ComposerStaticInitDAV
108108
'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/PropFilter.php',
109109
'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php',
110110
'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php',
111+
'OCA\\DAV\\CalDAV\\Security\\RateLimitingPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/Security/RateLimitingPlugin.php',
111112
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php',
112113
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObjectsCollection' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php',
113114
'OCA\\DAV\\CalDAV\\Trashbin\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/Plugin.php',

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,27 @@ public function getCalendarsForUserCount($principalUri, $excludeBirthday = true)
269269
return $column;
270270
}
271271

272+
/**
273+
* Return the number of subscriptions for a principal
274+
*/
275+
public function getSubscriptionsForUserCount(string $principalUri): int {
276+
$principalUri = $this->convertPrincipal($principalUri, true);
277+
$query = $this->db->getQueryBuilder();
278+
$query->select($query->func()->count('*'))
279+
->from('calendarsubscriptions');
280+
281+
if ($principalUri === '') {
282+
$query->where($query->expr()->emptyString('principaluri'));
283+
} else {
284+
$query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
285+
}
286+
287+
$result = $query->executeQuery();
288+
$column = (int)$result->fetchOne();
289+
$result->closeCursor();
290+
return $column;
291+
}
292+
272293
/**
273294
* @return array{id: int, deleted_at: int}[]
274295
*/
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* @copyright 2023 Christoph Wurst <[email protected]>
7+
*
8+
* @author 2023 Christoph Wurst <[email protected]>
9+
*
10+
* @license GNU AGPL version 3 or any later version
11+
*
12+
* This program is free software: you can redistribute it and/or modify
13+
* it under the terms of the GNU Affero General Public License as
14+
* published by the Free Software Foundation, either version 3 of the
15+
* License, or (at your option) any later version.
16+
*
17+
* This program is distributed in the hope that it will be useful,
18+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
* GNU Affero General Public License for more details.
21+
*
22+
* You should have received a copy of the GNU Affero General Public License
23+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
24+
*/
25+
26+
namespace OCA\DAV\CalDAV\Security;
27+
28+
use OC\Security\RateLimiting\Exception\RateLimitExceededException;
29+
use OC\Security\RateLimiting\Limiter;
30+
use OCA\DAV\CalDAV\CalDavBackend;
31+
use OCA\DAV\Connector\Sabre\Exception\TooManyRequests;
32+
use OCP\IConfig;
33+
use OCP\IUserManager;
34+
use Psr\Log\LoggerInterface;
35+
use Sabre\DAV;
36+
use Sabre\DAV\Exception\Forbidden;
37+
use Sabre\DAV\ServerPlugin;
38+
use function count;
39+
use function explode;
40+
41+
class RateLimitingPlugin extends ServerPlugin {
42+
43+
private Limiter $limiter;
44+
private IUserManager $userManager;
45+
private CalDavBackend $calDavBackend;
46+
private IConfig $config;
47+
private LoggerInterface $logger;
48+
private ?string $userId;
49+
50+
public function __construct(Limiter $limiter,
51+
IUserManager $userManager,
52+
CalDavBackend $calDavBackend,
53+
LoggerInterface $logger,
54+
IConfig $config,
55+
?string $userId) {
56+
$this->limiter = $limiter;
57+
$this->userManager = $userManager;
58+
$this->calDavBackend = $calDavBackend;
59+
$this->config = $config;
60+
$this->logger = $logger;
61+
$this->userId = $userId;
62+
}
63+
64+
public function initialize(DAV\Server $server): void {
65+
$server->on('beforeBind', [$this, 'beforeBind'], 1);
66+
}
67+
68+
public function beforeBind(string $path): void {
69+
if ($this->userId === null) {
70+
// We only care about authenticated users here
71+
return;
72+
}
73+
$user = $this->userManager->get($this->userId);
74+
if ($user === null) {
75+
// We only care about authenticated users here
76+
return;
77+
}
78+
79+
$pathParts = explode('/', $path);
80+
if (count($pathParts) === 3 && $pathParts[0] === 'calendars') {
81+
// Path looks like calendars/username/calendarname so a new calendar or subscription is created
82+
try {
83+
$this->limiter->registerUserRequest(
84+
'caldav-create-calendar',
85+
(int) $this->config->getAppValue('dav', 'rateLimitCalendarCreation', '10'),
86+
(int) $this->config->getAppValue('dav', 'rateLimitPeriodCalendarCreation', '3600'),
87+
$user
88+
);
89+
} catch (RateLimitExceededException $e) {
90+
throw new TooManyRequests('Too many calendars created', 0, $e);
91+
}
92+
93+
$calendarLimit = (int) $this->config->getAppValue('dav', 'maximumCalendarsSubscriptions', '30');
94+
if ($calendarLimit === -1) {
95+
return;
96+
}
97+
$numCalendars = $this->calDavBackend->getCalendarsForUserCount('principals/users/' . $user->getUID());
98+
$numSubscriptions = $this->calDavBackend->getSubscriptionsForUserCount('principals/users/' . $user->getUID());
99+
100+
if (($numCalendars + $numSubscriptions) >= $calendarLimit) {
101+
$this->logger->warning('Maximum number of calendars/subscriptions reached', [
102+
'calendars' => $numCalendars,
103+
'subscription' => $numSubscriptions,
104+
'limit' => $calendarLimit,
105+
]);
106+
throw new Forbidden('Calendar limit reached', 0);
107+
}
108+
}
109+
}
110+
111+
}

apps/dav/lib/Server.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
use OCA\DAV\AppInfo\PluginManager;
3838
use OCA\DAV\BulkUpload\BulkUploadPlugin;
3939
use OCA\DAV\CalDAV\BirthdayService;
40+
use OCA\DAV\CalDAV\Security\RateLimitingPlugin;
4041
use OCA\DAV\CardDAV\HasPhotoPlugin;
4142
use OCA\DAV\CardDAV\ImageExportPlugin;
4243
use OCA\DAV\CardDAV\MultiGetExportPlugin;
@@ -185,6 +186,8 @@ public function __construct(IRequest $request, string $baseUri) {
185186
\OC::$server->getConfig(),
186187
\OC::$server->getURLGenerator()
187188
));
189+
190+
$this->server->addPlugin(\OCP\Server::get(RateLimitingPlugin::class));
188191
}
189192

190193
// addressbook plugins
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* @copyright 2023 Christoph Wurst <[email protected]>
7+
*
8+
* @author 2023 Christoph Wurst <[email protected]>
9+
*
10+
* @license GNU AGPL version 3 or any later version
11+
*
12+
* This program is free software: you can redistribute it and/or modify
13+
* it under the terms of the GNU Affero General Public License as
14+
* published by the Free Software Foundation, either version 3 of the
15+
* License, or (at your option) any later version.
16+
*
17+
* This program is distributed in the hope that it will be useful,
18+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
* GNU Affero General Public License for more details.
21+
*
22+
* You should have received a copy of the GNU Affero General Public License
23+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
24+
*/
25+
26+
namespace OCA\DAV\Tests\unit\CalDAV\Security;
27+
28+
use OC\Security\RateLimiting\Exception\RateLimitExceededException;
29+
use OC\Security\RateLimiting\Limiter;
30+
use OCA\DAV\CalDAV\CalDavBackend;
31+
use OCA\DAV\CalDAV\Security\RateLimitingPlugin;
32+
use OCA\DAV\Connector\Sabre\Exception\TooManyRequests;
33+
use OCP\IConfig;
34+
use OCP\IUser;
35+
use OCP\IUserManager;
36+
use PHPUnit\Framework\MockObject\MockObject;
37+
use Psr\Log\LoggerInterface;
38+
use Sabre\DAV\Exception\Forbidden;
39+
use Test\TestCase;
40+
41+
class RateLimitingPluginTest extends TestCase {
42+
43+
private Limiter|MockObject $limiter;
44+
private CalDavBackend|MockObject $caldavBackend;
45+
private IUserManager|MockObject $userManager;
46+
private LoggerInterface|MockObject $logger;
47+
private IConfig|MockObject $config;
48+
private string $userId = 'user123';
49+
private RateLimitingPlugin $plugin;
50+
51+
protected function setUp(): void {
52+
parent::setUp();
53+
54+
$this->limiter = $this->createMock(Limiter::class);
55+
$this->userManager = $this->createMock(IUserManager::class);
56+
$this->caldavBackend = $this->createMock(CalDavBackend::class);
57+
$this->logger = $this->createMock(LoggerInterface::class);
58+
$this->config = $this->createMock(IConfig::class);
59+
$this->plugin = new RateLimitingPlugin(
60+
$this->limiter,
61+
$this->userManager,
62+
$this->caldavBackend,
63+
$this->logger,
64+
$this->config,
65+
$this->userId,
66+
);
67+
}
68+
69+
public function testNoUserObject(): void {
70+
$this->limiter->expects(self::never())
71+
->method('registerUserRequest');
72+
73+
$this->plugin->beforeBind('calendars/foo/cal');
74+
}
75+
76+
public function testUnrelated(): void {
77+
$user = $this->createMock(IUser::class);
78+
$this->userManager->expects(self::once())
79+
->method('get')
80+
->with($this->userId)
81+
->willReturn($user);
82+
$this->limiter->expects(self::never())
83+
->method('registerUserRequest');
84+
85+
$this->plugin->beforeBind('foo/bar');
86+
}
87+
88+
public function testRegisterCalendarCreation(): void {
89+
$user = $this->createMock(IUser::class);
90+
$this->userManager->expects(self::once())
91+
->method('get')
92+
->with($this->userId)
93+
->willReturn($user);
94+
$this->config
95+
->method('getAppValue')
96+
->with('dav')
97+
->willReturnArgument(2);
98+
$this->limiter->expects(self::once())
99+
->method('registerUserRequest')
100+
->with(
101+
'caldav-create-calendar',
102+
10,
103+
3600,
104+
$user,
105+
);
106+
107+
$this->plugin->beforeBind('calendars/foo/cal');
108+
}
109+
110+
public function testCalendarCreationRateLimitExceeded(): void {
111+
$user = $this->createMock(IUser::class);
112+
$this->userManager->expects(self::once())
113+
->method('get')
114+
->with($this->userId)
115+
->willReturn($user);
116+
$this->config
117+
->method('getAppValue')
118+
->with('dav')
119+
->willReturnArgument(2);
120+
$this->limiter->expects(self::once())
121+
->method('registerUserRequest')
122+
->with(
123+
'caldav-create-calendar',
124+
10,
125+
3600,
126+
$user,
127+
)
128+
->willThrowException(new RateLimitExceededException());
129+
$this->expectException(TooManyRequests::class);
130+
131+
$this->plugin->beforeBind('calendars/foo/cal');
132+
}
133+
134+
public function testCalendarLimitReached(): void {
135+
$user = $this->createMock(IUser::class);
136+
$this->userManager->expects(self::once())
137+
->method('get')
138+
->with($this->userId)
139+
->willReturn($user);
140+
$user->method('getUID')->willReturn('user123');
141+
$this->config
142+
->method('getAppValue')
143+
->with('dav')
144+
->willReturnArgument(2);
145+
$this->limiter->expects(self::once())
146+
->method('registerUserRequest')
147+
->with(
148+
'caldav-create-calendar',
149+
10,
150+
3600,
151+
$user,
152+
);
153+
$this->caldavBackend->expects(self::once())
154+
->method('getCalendarsForUserCount')
155+
->with('principals/users/user123')
156+
->willReturn(27);
157+
$this->caldavBackend->expects(self::once())
158+
->method('getSubscriptionsForUserCount')
159+
->with('principals/users/user123')
160+
->willReturn(3);
161+
$this->expectException(Forbidden::class);
162+
163+
$this->plugin->beforeBind('calendars/foo/cal');
164+
}
165+
166+
}

0 commit comments

Comments
 (0)