Skip to content

Commit 4c6eb96

Browse files
authored
Merge pull request #22280 from nextcloud/bugfix/noid/429-on-brute-force-maximum
Send "429 Too Many Requests" in case of brute force protection
2 parents 60be722 + e93bf71 commit 4c6eb96

File tree

11 files changed

+188
-52
lines changed

11 files changed

+188
-52
lines changed

build/psalm-baseline.xml

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6067,11 +6067,6 @@
60676067
<code>OC_User::getUser()</code>
60686068
</InvalidScalarArgument>
60696069
</file>
6070-
<file src="lib/private/legacy/OC_Template.php">
6071-
<ImplementedReturnTypeMismatch occurrences="1">
6072-
<code>boolean|string</code>
6073-
</ImplementedReturnTypeMismatch>
6074-
</file>
60756070
<file src="lib/private/legacy/OC_User.php">
60766071
<UndefinedClass occurrences="1">
60776072
<code>\Test\Util\User\Dummy</code>
@@ -6153,14 +6148,6 @@
61536148
<file src="lib/public/AppFramework/Http/Template/PublicTemplateResponse.php">
61546149
<InvalidScalarArgument occurrences="1"/>
61556150
</file>
6156-
<file src="lib/public/AppFramework/Http/TemplateResponse.php">
6157-
<InvalidReturnStatement occurrences="1">
6158-
<code>$template-&gt;fetchPage($this-&gt;params)</code>
6159-
</InvalidReturnStatement>
6160-
<InvalidReturnType occurrences="1">
6161-
<code>string</code>
6162-
</InvalidReturnType>
6163-
</file>
61646151
<file src="lib/public/AppFramework/Http/ZipResponse.php">
61656152
<InvalidArrayAccess occurrences="5">
61666153
<code>$resource['size']</code>

core/templates/429.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<div class="body-login-container update">
2+
<h2><?php p($l->t('Too many requests')); ?></h2>
3+
<p class="infogroup"><?php p($l->t('There were too many requests from your network. Retry later or contact your administrator if this is an error.')); ?></p>
4+
</div>

lib/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
'OCP\\AppFramework\\Http\\Template\\LinkMenuAction' => $baseDir . '/lib/public/AppFramework/Http/Template/LinkMenuAction.php',
6565
'OCP\\AppFramework\\Http\\Template\\PublicTemplateResponse' => $baseDir . '/lib/public/AppFramework/Http/Template/PublicTemplateResponse.php',
6666
'OCP\\AppFramework\\Http\\Template\\SimpleMenuAction' => $baseDir . '/lib/public/AppFramework/Http/Template/SimpleMenuAction.php',
67+
'OCP\\AppFramework\\Http\\TooManyRequestsResponse' => $baseDir . '/lib/public/AppFramework/Http/TooManyRequestsResponse.php',
6768
'OCP\\AppFramework\\Http\\ZipResponse' => $baseDir . '/lib/public/AppFramework/Http/ZipResponse.php',
6869
'OCP\\AppFramework\\IAppContainer' => $baseDir . '/lib/public/AppFramework/IAppContainer.php',
6970
'OCP\\AppFramework\\Middleware' => $baseDir . '/lib/public/AppFramework/Middleware.php',
@@ -448,6 +449,7 @@
448449
'OCP\\Search\\Result' => $baseDir . '/lib/public/Search/Result.php',
449450
'OCP\\Search\\SearchResult' => $baseDir . '/lib/public/Search/SearchResult.php',
450451
'OCP\\Search\\SearchResultEntry' => $baseDir . '/lib/public/Search/SearchResultEntry.php',
452+
'OCP\\Security\\Bruteforce\\MaxDelayReached' => $baseDir . '/lib/public/Security/Bruteforce/MaxDelayReached.php',
451453
'OCP\\Security\\CSP\\AddContentSecurityPolicyEvent' => $baseDir . '/lib/public/Security/CSP/AddContentSecurityPolicyEvent.php',
452454
'OCP\\Security\\Events\\GenerateSecurePasswordEvent' => $baseDir . '/lib/public/Security/Events/GenerateSecurePasswordEvent.php',
453455
'OCP\\Security\\Events\\ValidatePasswordPolicyEvent' => $baseDir . '/lib/public/Security/Events/ValidatePasswordPolicyEvent.php',

lib/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
9393
'OCP\\AppFramework\\Http\\Template\\LinkMenuAction' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Template/LinkMenuAction.php',
9494
'OCP\\AppFramework\\Http\\Template\\PublicTemplateResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Template/PublicTemplateResponse.php',
9595
'OCP\\AppFramework\\Http\\Template\\SimpleMenuAction' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Template/SimpleMenuAction.php',
96+
'OCP\\AppFramework\\Http\\TooManyRequestsResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/TooManyRequestsResponse.php',
9697
'OCP\\AppFramework\\Http\\ZipResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/ZipResponse.php',
9798
'OCP\\AppFramework\\IAppContainer' => __DIR__ . '/../../..' . '/lib/public/AppFramework/IAppContainer.php',
9899
'OCP\\AppFramework\\Middleware' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Middleware.php',
@@ -477,6 +478,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
477478
'OCP\\Search\\Result' => __DIR__ . '/../../..' . '/lib/public/Search/Result.php',
478479
'OCP\\Search\\SearchResult' => __DIR__ . '/../../..' . '/lib/public/Search/SearchResult.php',
479480
'OCP\\Search\\SearchResultEntry' => __DIR__ . '/../../..' . '/lib/public/Search/SearchResultEntry.php',
481+
'OCP\\Security\\Bruteforce\\MaxDelayReached' => __DIR__ . '/../../..' . '/lib/public/Security/Bruteforce/MaxDelayReached.php',
480482
'OCP\\Security\\CSP\\AddContentSecurityPolicyEvent' => __DIR__ . '/../../..' . '/lib/public/Security/CSP/AddContentSecurityPolicyEvent.php',
481483
'OCP\\Security\\Events\\GenerateSecurePasswordEvent' => __DIR__ . '/../../..' . '/lib/public/Security/Events/GenerateSecurePasswordEvent.php',
482484
'OCP\\Security\\Events\\ValidatePasswordPolicyEvent' => __DIR__ . '/../../..' . '/lib/public/Security/Events/ValidatePasswordPolicyEvent.php',

lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<?php
2+
3+
declare(strict_types=1);
24
/**
35
* @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch>
46
*
@@ -26,9 +28,15 @@
2628

2729
use OC\AppFramework\Utility\ControllerMethodReflector;
2830
use OC\Security\Bruteforce\Throttler;
31+
use OCP\AppFramework\Controller;
32+
use OCP\AppFramework\Http;
2933
use OCP\AppFramework\Http\Response;
34+
use OCP\AppFramework\Http\TooManyRequestsResponse;
3035
use OCP\AppFramework\Middleware;
36+
use OCP\AppFramework\OCS\OCSException;
37+
use OCP\AppFramework\OCSController;
3138
use OCP\IRequest;
39+
use OCP\Security\Bruteforce\MaxDelayReached;
3240

3341
/**
3442
* Class BruteForceMiddleware performs the bruteforce protection for controllers
@@ -66,7 +74,7 @@ public function beforeController($controller, $methodName) {
6674

6775
if ($this->reflector->hasAnnotation('BruteForceProtection')) {
6876
$action = $this->reflector->getAnnotationParameter('BruteForceProtection', 'action');
69-
$this->throttler->sleepDelay($this->request->getRemoteAddress(), $action);
77+
$this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), $action);
7078
}
7179
}
7280

@@ -83,4 +91,23 @@ public function afterController($controller, $methodName, Response $response) {
8391

8492
return parent::afterController($controller, $methodName, $response);
8593
}
94+
95+
/**
96+
* @param Controller $controller
97+
* @param string $methodName
98+
* @param \Exception $exception
99+
* @throws \Exception
100+
* @return Response
101+
*/
102+
public function afterException($controller, $methodName, \Exception $exception): Response {
103+
if ($exception instanceof MaxDelayReached) {
104+
if ($controller instanceof OCSController) {
105+
throw new OCSException($exception->getMessage(), Http::STATUS_TOO_MANY_REQUESTS);
106+
}
107+
108+
return new TooManyRequestsResponse();
109+
}
110+
111+
throw $exception;
112+
}
86113
}

lib/private/Security/Bruteforce/Throttler.php

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<?php
2+
3+
declare(strict_types=1);
24
/**
35
* @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch>
46
*
@@ -34,6 +36,7 @@
3436
use OCP\IConfig;
3537
use OCP\IDBConnection;
3638
use OCP\ILogger;
39+
use OCP\Security\Bruteforce\MaxDelayReached;
3740

3841
/**
3942
* Class Throttler implements the bruteforce protection for security actions in
@@ -50,6 +53,9 @@
5053
*/
5154
class Throttler {
5255
public const LOGIN_ACTION = 'login';
56+
public const MAX_DELAY = 25;
57+
public const MAX_DELAY_MS = 25000; // in milliseconds
58+
public const MAX_ATTEMPTS = 10;
5359

5460
/** @var IDBConnection */
5561
private $db;
@@ -82,7 +88,7 @@ public function __construct(IDBConnection $db,
8288
* @param int $expire
8389
* @return \DateInterval
8490
*/
85-
private function getCutoff($expire) {
91+
private function getCutoff(int $expire): \DateInterval {
8692
$d1 = new \DateTime();
8793
$d2 = clone $d1;
8894
$d2->sub(new \DateInterval('PT' . $expire . 'S'));
@@ -92,11 +98,12 @@ private function getCutoff($expire) {
9298
/**
9399
* Calculate the cut off timestamp
94100
*
101+
* @param float $maxAgeHours
95102
* @return int
96103
*/
97-
private function getCutoffTimestamp(): int {
104+
private function getCutoffTimestamp(float $maxAgeHours = 12.0): int {
98105
return (new \DateTime())
99-
->sub($this->getCutoff(43200))
106+
->sub($this->getCutoff((int) ($maxAgeHours * 3600)))
100107
->getTimestamp();
101108
}
102109

@@ -108,9 +115,9 @@ private function getCutoffTimestamp(): int {
108115
* @param array $metadata Optional metadata logged to the database
109116
* @suppress SqlInjectionChecker
110117
*/
111-
public function registerAttempt($action,
112-
$ip,
113-
array $metadata = []) {
118+
public function registerAttempt(string $action,
119+
string $ip,
120+
array $metadata = []): void {
114121
// No need to log if the bruteforce protection is disabled
115122
if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
116123
return;
@@ -150,15 +157,14 @@ public function registerAttempt($action,
150157
* @param string $ip
151158
* @return bool
152159
*/
153-
private function isIPWhitelisted($ip) {
160+
private function isIPWhitelisted(string $ip): bool {
154161
if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
155162
return true;
156163
}
157164

158165
$keys = $this->config->getAppKeys('bruteForce');
159166
$keys = array_filter($keys, function ($key) {
160-
$regex = '/^whitelist_/S';
161-
return preg_match($regex, $key) === 1;
167+
return 0 === strpos($key, 'whitelist_');
162168
});
163169

164170
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
@@ -215,18 +221,19 @@ private function isIPWhitelisted($ip) {
215221
*
216222
* @param string $ip
217223
* @param string $action optionally filter by action
224+
* @param float $maxAgeHours
218225
* @return int
219226
*/
220-
public function getDelay($ip, $action = '') {
227+
public function getAttempts(string $ip, string $action = '', float $maxAgeHours = 12): int {
221228
$ipAddress = new IpAddress($ip);
222229
if ($this->isIPWhitelisted((string)$ipAddress)) {
223230
return 0;
224231
}
225232

226-
$cutoffTime = $this->getCutoffTimestamp();
233+
$cutoffTime = $this->getCutoffTimestamp($maxAgeHours);
227234

228235
$qb = $this->db->getQueryBuilder();
229-
$qb->select('*')
236+
$qb->select($qb->func()->count('*', 'attempts'))
230237
->from('bruteforce_attempts')
231238
->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
232239
->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())));
@@ -235,34 +242,47 @@ public function getDelay($ip, $action = '') {
235242
$qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)));
236243
}
237244

238-
$attempts = count($qb->execute()->fetchAll());
245+
$result = $qb->execute();
246+
$row = $result->fetch();
247+
$result->closeCursor();
248+
249+
return (int) $row['attempts'];
250+
}
239251

252+
/**
253+
* Get the throttling delay (in milliseconds)
254+
*
255+
* @param string $ip
256+
* @param string $action optionally filter by action
257+
* @return int
258+
*/
259+
public function getDelay(string $ip, string $action = ''): int {
260+
$attempts = $this->getAttempts($ip, $action);
240261
if ($attempts === 0) {
241262
return 0;
242263
}
243264

244-
$maxDelay = 25;
245265
$firstDelay = 0.1;
246-
if ($attempts > (8 * PHP_INT_SIZE - 1)) {
266+
if ($attempts > self::MAX_ATTEMPTS) {
247267
// Don't ever overflow. Just assume the maxDelay time:s
248-
$firstDelay = $maxDelay;
249-
} else {
250-
$firstDelay *= pow(2, $attempts);
251-
if ($firstDelay > $maxDelay) {
252-
$firstDelay = $maxDelay;
253-
}
268+
return self::MAX_DELAY_MS;
254269
}
255-
return (int) \ceil($firstDelay * 1000);
270+
271+
$delay = $firstDelay * 2**$attempts;
272+
if ($delay > self::MAX_DELAY) {
273+
return self::MAX_DELAY_MS;
274+
}
275+
return (int) \ceil($delay * 1000);
256276
}
257277

258278
/**
259279
* Reset the throttling delay for an IP address, action and metadata
260280
*
261281
* @param string $ip
262282
* @param string $action
263-
* @param string $metadata
283+
* @param array $metadata
264284
*/
265-
public function resetDelay($ip, $action, $metadata) {
285+
public function resetDelay(string $ip, string $action, array $metadata): void {
266286
$ipAddress = new IpAddress($ip);
267287
if ($this->isIPWhitelisted((string)$ipAddress)) {
268288
return;
@@ -303,9 +323,28 @@ public function resetDelayForIP($ip) {
303323
* @param string $action optionally filter by action
304324
* @return int the time spent sleeping
305325
*/
306-
public function sleepDelay($ip, $action = '') {
326+
public function sleepDelay(string $ip, string $action = ''): int {
307327
$delay = $this->getDelay($ip, $action);
308328
usleep($delay * 1000);
309329
return $delay;
310330
}
331+
332+
/**
333+
* Will sleep for the defined amount of time unless maximum was reached in the last 30 minutes
334+
* In this case a "429 Too Many Request" exception is thrown
335+
*
336+
* @param string $ip
337+
* @param string $action optionally filter by action
338+
* @return int the time spent sleeping
339+
* @throws MaxDelayReached when reached the maximum
340+
*/
341+
public function sleepDelayOrThrowOnMax(string $ip, string $action = ''): int {
342+
$delay = $this->getDelay($ip, $action);
343+
if (($delay === self::MAX_DELAY_MS) && $this->getAttempts($ip, $action, 0.5) > self::MAX_ATTEMPTS) {
344+
// If the ip made too many attempts within the last 30 mins we don't execute anymore
345+
throw new MaxDelayReached('Reached maximum delay');
346+
}
347+
usleep($delay * 1000);
348+
return $delay;
349+
}
311350
}

lib/private/legacy/OC_Template.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ public function addHeader($tag, $attributes, $text=null) {
171171

172172
/**
173173
* Process the template
174-
* @return boolean|string
174+
* @return string
175175
*
176176
* This function process the template. If $this->renderAs is set, it
177177
* will produce a full page.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* @copyright Copyright (c) 2020 Joas Schilling <coding@schilljs.com>
6+
*
7+
* @license GNU AGPL version 3 or any later version
8+
*
9+
* This program is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU Affero General Public License as
11+
* published by the Free Software Foundation, either version 3 of the
12+
* License, or (at your option) any later version.
13+
*
14+
* This program is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
* GNU Affero General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU Affero General Public License
20+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
21+
*
22+
*/
23+
24+
namespace OCP\AppFramework\Http;
25+
26+
use OCP\Template;
27+
28+
/**
29+
* A generic 429 response showing an 404 error page as well to the end-user
30+
* @since 19.0.0
31+
*/
32+
class TooManyRequestsResponse extends Response {
33+
34+
/**
35+
* @since 19.0.0
36+
*/
37+
public function __construct() {
38+
parent::__construct();
39+
40+
$this->setContentSecurityPolicy(new ContentSecurityPolicy());
41+
$this->setStatus(429);
42+
}
43+
44+
/**
45+
* @return string
46+
* @since 19.0.0
47+
*/
48+
public function render() {
49+
$template = new Template('core', '429', 'blank');
50+
return $template->fetchPage();
51+
}
52+
}

0 commit comments

Comments
 (0)