diff options
author | Morris Jobke <hey@morrisjobke.de> | 2020-08-19 18:21:01 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-08-19 18:21:01 +0200 |
commit | 4c6eb96471b90c41e26bdf2cb3edf2cb15aec613 (patch) | |
tree | 49ea3b11915636e3f87640e24178b180939c8848 /lib | |
parent | 60be722ee8781d9e94ecc66d62c0e5fcb7e3934e (diff) | |
parent | e93bf713690047da6e48f882848c7f1ba832db4e (diff) | |
download | nextcloud-server-4c6eb96471b90c41e26bdf2cb3edf2cb15aec613.tar.gz nextcloud-server-4c6eb96471b90c41e26bdf2cb3edf2cb15aec613.zip |
Merge pull request #22280 from nextcloud/bugfix/noid/429-on-brute-force-maximum
Send "429 Too Many Requests" in case of brute force protection
Diffstat (limited to 'lib')
-rw-r--r-- | lib/composer/composer/autoload_classmap.php | 2 | ||||
-rw-r--r-- | lib/composer/composer/autoload_static.php | 2 | ||||
-rw-r--r-- | lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php | 29 | ||||
-rw-r--r-- | lib/private/Security/Bruteforce/Throttler.php | 89 | ||||
-rw-r--r-- | lib/private/legacy/OC_Template.php | 2 | ||||
-rw-r--r-- | lib/public/AppFramework/Http/TooManyRequestsResponse.php | 52 | ||||
-rw-r--r-- | lib/public/Security/Bruteforce/MaxDelayReached.php | 31 |
7 files changed, 180 insertions, 27 deletions
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index e063fe0b715..549b11c05ec 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -64,6 +64,7 @@ return array( 'OCP\\AppFramework\\Http\\Template\\LinkMenuAction' => $baseDir . '/lib/public/AppFramework/Http/Template/LinkMenuAction.php', 'OCP\\AppFramework\\Http\\Template\\PublicTemplateResponse' => $baseDir . '/lib/public/AppFramework/Http/Template/PublicTemplateResponse.php', 'OCP\\AppFramework\\Http\\Template\\SimpleMenuAction' => $baseDir . '/lib/public/AppFramework/Http/Template/SimpleMenuAction.php', + 'OCP\\AppFramework\\Http\\TooManyRequestsResponse' => $baseDir . '/lib/public/AppFramework/Http/TooManyRequestsResponse.php', 'OCP\\AppFramework\\Http\\ZipResponse' => $baseDir . '/lib/public/AppFramework/Http/ZipResponse.php', 'OCP\\AppFramework\\IAppContainer' => $baseDir . '/lib/public/AppFramework/IAppContainer.php', 'OCP\\AppFramework\\Middleware' => $baseDir . '/lib/public/AppFramework/Middleware.php', @@ -448,6 +449,7 @@ return array( 'OCP\\Search\\Result' => $baseDir . '/lib/public/Search/Result.php', 'OCP\\Search\\SearchResult' => $baseDir . '/lib/public/Search/SearchResult.php', 'OCP\\Search\\SearchResultEntry' => $baseDir . '/lib/public/Search/SearchResultEntry.php', + 'OCP\\Security\\Bruteforce\\MaxDelayReached' => $baseDir . '/lib/public/Security/Bruteforce/MaxDelayReached.php', 'OCP\\Security\\CSP\\AddContentSecurityPolicyEvent' => $baseDir . '/lib/public/Security/CSP/AddContentSecurityPolicyEvent.php', 'OCP\\Security\\Events\\GenerateSecurePasswordEvent' => $baseDir . '/lib/public/Security/Events/GenerateSecurePasswordEvent.php', 'OCP\\Security\\Events\\ValidatePasswordPolicyEvent' => $baseDir . '/lib/public/Security/Events/ValidatePasswordPolicyEvent.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 20394b4ae30..1a771332437 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -93,6 +93,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OCP\\AppFramework\\Http\\Template\\LinkMenuAction' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Template/LinkMenuAction.php', 'OCP\\AppFramework\\Http\\Template\\PublicTemplateResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Template/PublicTemplateResponse.php', 'OCP\\AppFramework\\Http\\Template\\SimpleMenuAction' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Template/SimpleMenuAction.php', + 'OCP\\AppFramework\\Http\\TooManyRequestsResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/TooManyRequestsResponse.php', 'OCP\\AppFramework\\Http\\ZipResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/ZipResponse.php', 'OCP\\AppFramework\\IAppContainer' => __DIR__ . '/../../..' . '/lib/public/AppFramework/IAppContainer.php', 'OCP\\AppFramework\\Middleware' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Middleware.php', @@ -477,6 +478,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OCP\\Search\\Result' => __DIR__ . '/../../..' . '/lib/public/Search/Result.php', 'OCP\\Search\\SearchResult' => __DIR__ . '/../../..' . '/lib/public/Search/SearchResult.php', 'OCP\\Search\\SearchResultEntry' => __DIR__ . '/../../..' . '/lib/public/Search/SearchResultEntry.php', + 'OCP\\Security\\Bruteforce\\MaxDelayReached' => __DIR__ . '/../../..' . '/lib/public/Security/Bruteforce/MaxDelayReached.php', 'OCP\\Security\\CSP\\AddContentSecurityPolicyEvent' => __DIR__ . '/../../..' . '/lib/public/Security/CSP/AddContentSecurityPolicyEvent.php', 'OCP\\Security\\Events\\GenerateSecurePasswordEvent' => __DIR__ . '/../../..' . '/lib/public/Security/Events/GenerateSecurePasswordEvent.php', 'OCP\\Security\\Events\\ValidatePasswordPolicyEvent' => __DIR__ . '/../../..' . '/lib/public/Security/Events/ValidatePasswordPolicyEvent.php', diff --git a/lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php b/lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php index 398c2f3f3a4..e6c511537a0 100644 --- a/lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php +++ b/lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php @@ -1,4 +1,6 @@ <?php + +declare(strict_types=1); /** * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch> * @@ -26,9 +28,15 @@ namespace OC\AppFramework\Middleware\Security; use OC\AppFramework\Utility\ControllerMethodReflector; use OC\Security\Bruteforce\Throttler; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TooManyRequestsResponse; use OCP\AppFramework\Middleware; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCSController; use OCP\IRequest; +use OCP\Security\Bruteforce\MaxDelayReached; /** * Class BruteForceMiddleware performs the bruteforce protection for controllers @@ -66,7 +74,7 @@ class BruteForceMiddleware extends Middleware { if ($this->reflector->hasAnnotation('BruteForceProtection')) { $action = $this->reflector->getAnnotationParameter('BruteForceProtection', 'action'); - $this->throttler->sleepDelay($this->request->getRemoteAddress(), $action); + $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), $action); } } @@ -83,4 +91,23 @@ class BruteForceMiddleware extends Middleware { return parent::afterController($controller, $methodName, $response); } + + /** + * @param Controller $controller + * @param string $methodName + * @param \Exception $exception + * @throws \Exception + * @return Response + */ + public function afterException($controller, $methodName, \Exception $exception): Response { + if ($exception instanceof MaxDelayReached) { + if ($controller instanceof OCSController) { + throw new OCSException($exception->getMessage(), Http::STATUS_TOO_MANY_REQUESTS); + } + + return new TooManyRequestsResponse(); + } + + throw $exception; + } } diff --git a/lib/private/Security/Bruteforce/Throttler.php b/lib/private/Security/Bruteforce/Throttler.php index 63c6361b9ce..169ad0c0623 100644 --- a/lib/private/Security/Bruteforce/Throttler.php +++ b/lib/private/Security/Bruteforce/Throttler.php @@ -1,4 +1,6 @@ <?php + +declare(strict_types=1); /** * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch> * @@ -34,6 +36,7 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; use OCP\IDBConnection; use OCP\ILogger; +use OCP\Security\Bruteforce\MaxDelayReached; /** * Class Throttler implements the bruteforce protection for security actions in @@ -50,6 +53,9 @@ use OCP\ILogger; */ class Throttler { public const LOGIN_ACTION = 'login'; + public const MAX_DELAY = 25; + public const MAX_DELAY_MS = 25000; // in milliseconds + public const MAX_ATTEMPTS = 10; /** @var IDBConnection */ private $db; @@ -82,7 +88,7 @@ class Throttler { * @param int $expire * @return \DateInterval */ - private function getCutoff($expire) { + private function getCutoff(int $expire): \DateInterval { $d1 = new \DateTime(); $d2 = clone $d1; $d2->sub(new \DateInterval('PT' . $expire . 'S')); @@ -92,11 +98,12 @@ class Throttler { /** * Calculate the cut off timestamp * + * @param float $maxAgeHours * @return int */ - private function getCutoffTimestamp(): int { + private function getCutoffTimestamp(float $maxAgeHours = 12.0): int { return (new \DateTime()) - ->sub($this->getCutoff(43200)) + ->sub($this->getCutoff((int) ($maxAgeHours * 3600))) ->getTimestamp(); } @@ -108,9 +115,9 @@ class Throttler { * @param array $metadata Optional metadata logged to the database * @suppress SqlInjectionChecker */ - public function registerAttempt($action, - $ip, - array $metadata = []) { + public function registerAttempt(string $action, + string $ip, + array $metadata = []): void { // No need to log if the bruteforce protection is disabled if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) { return; @@ -150,15 +157,14 @@ class Throttler { * @param string $ip * @return bool */ - private function isIPWhitelisted($ip) { + private function isIPWhitelisted(string $ip): bool { if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) { return true; } $keys = $this->config->getAppKeys('bruteForce'); $keys = array_filter($keys, function ($key) { - $regex = '/^whitelist_/S'; - return preg_match($regex, $key) === 1; + return 0 === strpos($key, 'whitelist_'); }); if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { @@ -215,18 +221,19 @@ class Throttler { * * @param string $ip * @param string $action optionally filter by action + * @param float $maxAgeHours * @return int */ - public function getDelay($ip, $action = '') { + public function getAttempts(string $ip, string $action = '', float $maxAgeHours = 12): int { $ipAddress = new IpAddress($ip); if ($this->isIPWhitelisted((string)$ipAddress)) { return 0; } - $cutoffTime = $this->getCutoffTimestamp(); + $cutoffTime = $this->getCutoffTimestamp($maxAgeHours); $qb = $this->db->getQueryBuilder(); - $qb->select('*') + $qb->select($qb->func()->count('*', 'attempts')) ->from('bruteforce_attempts') ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime))) ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet()))); @@ -235,24 +242,37 @@ class Throttler { $qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action))); } - $attempts = count($qb->execute()->fetchAll()); + $result = $qb->execute(); + $row = $result->fetch(); + $result->closeCursor(); + + return (int) $row['attempts']; + } + /** + * Get the throttling delay (in milliseconds) + * + * @param string $ip + * @param string $action optionally filter by action + * @return int + */ + public function getDelay(string $ip, string $action = ''): int { + $attempts = $this->getAttempts($ip, $action); if ($attempts === 0) { return 0; } - $maxDelay = 25; $firstDelay = 0.1; - if ($attempts > (8 * PHP_INT_SIZE - 1)) { + if ($attempts > self::MAX_ATTEMPTS) { // Don't ever overflow. Just assume the maxDelay time:s - $firstDelay = $maxDelay; - } else { - $firstDelay *= pow(2, $attempts); - if ($firstDelay > $maxDelay) { - $firstDelay = $maxDelay; - } + return self::MAX_DELAY_MS; } - return (int) \ceil($firstDelay * 1000); + + $delay = $firstDelay * 2**$attempts; + if ($delay > self::MAX_DELAY) { + return self::MAX_DELAY_MS; + } + return (int) \ceil($delay * 1000); } /** @@ -260,9 +280,9 @@ class Throttler { * * @param string $ip * @param string $action - * @param string $metadata + * @param array $metadata */ - public function resetDelay($ip, $action, $metadata) { + public function resetDelay(string $ip, string $action, array $metadata): void { $ipAddress = new IpAddress($ip); if ($this->isIPWhitelisted((string)$ipAddress)) { return; @@ -303,9 +323,28 @@ class Throttler { * @param string $action optionally filter by action * @return int the time spent sleeping */ - public function sleepDelay($ip, $action = '') { + public function sleepDelay(string $ip, string $action = ''): int { $delay = $this->getDelay($ip, $action); usleep($delay * 1000); return $delay; } + + /** + * Will sleep for the defined amount of time unless maximum was reached in the last 30 minutes + * In this case a "429 Too Many Request" exception is thrown + * + * @param string $ip + * @param string $action optionally filter by action + * @return int the time spent sleeping + * @throws MaxDelayReached when reached the maximum + */ + public function sleepDelayOrThrowOnMax(string $ip, string $action = ''): int { + $delay = $this->getDelay($ip, $action); + if (($delay === self::MAX_DELAY_MS) && $this->getAttempts($ip, $action, 0.5) > self::MAX_ATTEMPTS) { + // If the ip made too many attempts within the last 30 mins we don't execute anymore + throw new MaxDelayReached('Reached maximum delay'); + } + usleep($delay * 1000); + return $delay; + } } diff --git a/lib/private/legacy/OC_Template.php b/lib/private/legacy/OC_Template.php index 18a15ad1d43..54c203a3ab6 100644 --- a/lib/private/legacy/OC_Template.php +++ b/lib/private/legacy/OC_Template.php @@ -171,7 +171,7 @@ class OC_Template extends \OC\Template\Base { /** * Process the template - * @return boolean|string + * @return string * * This function process the template. If $this->renderAs is set, it * will produce a full page. diff --git a/lib/public/AppFramework/Http/TooManyRequestsResponse.php b/lib/public/AppFramework/Http/TooManyRequestsResponse.php new file mode 100644 index 00000000000..b15df303bfe --- /dev/null +++ b/lib/public/AppFramework/Http/TooManyRequestsResponse.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); +/** + * @copyright Copyright (c) 2020 Joas Schilling <coding@schilljs.com> + * + * @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 OCP\AppFramework\Http; + +use OCP\Template; + +/** + * A generic 429 response showing an 404 error page as well to the end-user + * @since 19.0.0 + */ +class TooManyRequestsResponse extends Response { + + /** + * @since 19.0.0 + */ + public function __construct() { + parent::__construct(); + + $this->setContentSecurityPolicy(new ContentSecurityPolicy()); + $this->setStatus(429); + } + + /** + * @return string + * @since 19.0.0 + */ + public function render() { + $template = new Template('core', '429', 'blank'); + return $template->fetchPage(); + } +} diff --git a/lib/public/Security/Bruteforce/MaxDelayReached.php b/lib/public/Security/Bruteforce/MaxDelayReached.php new file mode 100644 index 00000000000..3aaa7d05159 --- /dev/null +++ b/lib/public/Security/Bruteforce/MaxDelayReached.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); +/** + * @copyright Copyright (c) 2020 Joas Schilling <coding@schilljs.com> + * + * @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 OCP\Security\Bruteforce; + +/** + * Class MaxDelayReached + * @since 19.0 + */ +class MaxDelayReached extends \RuntimeException { +} |