diff options
Diffstat (limited to 'lib/private/Security/RateLimiting')
6 files changed, 256 insertions, 242 deletions
diff --git a/lib/private/Security/RateLimiting/Backend/DatabaseBackend.php b/lib/private/Security/RateLimiting/Backend/DatabaseBackend.php new file mode 100644 index 00000000000..9fb237f2f72 --- /dev/null +++ b/lib/private/Security/RateLimiting/Backend/DatabaseBackend.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\RateLimiting\Backend; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\Exception; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IConfig; +use OCP\IDBConnection; + +class DatabaseBackend implements IBackend { + private const TABLE_NAME = 'ratelimit_entries'; + + public function __construct( + private IConfig $config, + private IDBConnection $dbConnection, + private ITimeFactory $timeFactory, + ) { + } + + private function hash( + string $methodIdentifier, + string $userIdentifier, + ): string { + return hash('sha512', $methodIdentifier . $userIdentifier); + } + + /** + * @throws Exception + */ + private function getExistingAttemptCount( + string $identifier, + ): int { + $currentTime = $this->timeFactory->getDateTime(); + + $qb = $this->dbConnection->getQueryBuilder(); + $qb->delete(self::TABLE_NAME) + ->where( + $qb->expr()->lte('delete_after', $qb->createNamedParameter($currentTime, IQueryBuilder::PARAM_DATETIME_MUTABLE)) + ) + ->executeStatement(); + + $qb = $this->dbConnection->getQueryBuilder(); + $qb->select($qb->func()->count()) + ->from(self::TABLE_NAME) + ->where( + $qb->expr()->eq('hash', $qb->createNamedParameter($identifier, IQueryBuilder::PARAM_STR)) + ); + + $cursor = $qb->executeQuery(); + $row = $cursor->fetchOne(); + $cursor->closeCursor(); + + return (int)$row; + } + + /** + * {@inheritDoc} + */ + public function getAttempts( + string $methodIdentifier, + string $userIdentifier, + ): int { + $identifier = $this->hash($methodIdentifier, $userIdentifier); + return $this->getExistingAttemptCount($identifier); + } + + /** + * {@inheritDoc} + */ + public function registerAttempt( + string $methodIdentifier, + string $userIdentifier, + int $period, + ): void { + $identifier = $this->hash($methodIdentifier, $userIdentifier); + $deleteAfter = $this->timeFactory->getDateTime()->add(new \DateInterval("PT{$period}S")); + + $qb = $this->dbConnection->getQueryBuilder(); + + $qb->insert(self::TABLE_NAME) + ->values([ + 'hash' => $qb->createNamedParameter($identifier, IQueryBuilder::PARAM_STR), + 'delete_after' => $qb->createNamedParameter($deleteAfter, IQueryBuilder::PARAM_DATETIME_MUTABLE), + ]); + + if (!$this->config->getSystemValueBool('ratelimit.protection.enabled', true)) { + return; + } + + $qb->executeStatement(); + } +} diff --git a/lib/private/Security/RateLimiting/Backend/IBackend.php b/lib/private/Security/RateLimiting/Backend/IBackend.php index 57dd4e3cc6d..43eff5dcf02 100644 --- a/lib/private/Security/RateLimiting/Backend/IBackend.php +++ b/lib/private/Security/RateLimiting/Backend/IBackend.php @@ -3,28 +3,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch> - * - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Security\RateLimiting\Backend; /** @@ -36,16 +17,15 @@ namespace OC\Security\RateLimiting\Backend; */ interface IBackend { /** - * Gets the amount of attempts within the last specified seconds + * Gets the number of attempts for the specified method * * @param string $methodIdentifier Identifier for the method * @param string $userIdentifier Identifier for the user - * @param int $seconds Seconds to look back at - * @return int */ - public function getAttempts(string $methodIdentifier, - string $userIdentifier, - int $seconds): int; + public function getAttempts( + string $methodIdentifier, + string $userIdentifier, + ): int; /** * Registers an attempt @@ -54,7 +34,9 @@ interface IBackend { * @param string $userIdentifier Identifier for the user * @param int $period Period in seconds how long this attempt should be stored */ - public function registerAttempt(string $methodIdentifier, - string $userIdentifier, - int $period); + public function registerAttempt( + string $methodIdentifier, + string $userIdentifier, + int $period, + ); } diff --git a/lib/private/Security/RateLimiting/Backend/MemoryCache.php b/lib/private/Security/RateLimiting/Backend/MemoryCache.php deleted file mode 100644 index 2893d31ece2..00000000000 --- a/lib/private/Security/RateLimiting/Backend/MemoryCache.php +++ /dev/null @@ -1,129 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OC\Security\RateLimiting\Backend; - -use OCP\AppFramework\Utility\ITimeFactory; -use OCP\ICache; -use OCP\ICacheFactory; - -/** - * Class MemoryCache uses the configured distributed memory cache for storing - * rate limiting data. - * - * @package OC\Security\RateLimiting\Backend - */ -class MemoryCache implements IBackend { - /** @var ICache */ - private $cache; - /** @var ITimeFactory */ - private $timeFactory; - - /** - * @param ICacheFactory $cacheFactory - * @param ITimeFactory $timeFactory - */ - public function __construct(ICacheFactory $cacheFactory, - ITimeFactory $timeFactory) { - $this->cache = $cacheFactory->createDistributed(__CLASS__); - $this->timeFactory = $timeFactory; - } - - /** - * @param string $methodIdentifier - * @param string $userIdentifier - * @return string - */ - private function hash(string $methodIdentifier, - string $userIdentifier): string { - return hash('sha512', $methodIdentifier . $userIdentifier); - } - - /** - * @param string $identifier - * @return array - */ - private function getExistingAttempts(string $identifier): array { - $cachedAttempts = $this->cache->get($identifier); - if ($cachedAttempts === null) { - return []; - } - - $cachedAttempts = json_decode($cachedAttempts, true); - if (\is_array($cachedAttempts)) { - return $cachedAttempts; - } - - return []; - } - - /** - * {@inheritDoc} - */ - public function getAttempts(string $methodIdentifier, - string $userIdentifier, - int $seconds): int { - $identifier = $this->hash($methodIdentifier, $userIdentifier); - $existingAttempts = $this->getExistingAttempts($identifier); - - $count = 0; - $currentTime = $this->timeFactory->getTime(); - /** @var array $existingAttempts */ - foreach ($existingAttempts as $attempt) { - if (($attempt + $seconds) > $currentTime) { - $count++; - } - } - - return $count; - } - - /** - * {@inheritDoc} - */ - public function registerAttempt(string $methodIdentifier, - string $userIdentifier, - int $period) { - $identifier = $this->hash($methodIdentifier, $userIdentifier); - $existingAttempts = $this->getExistingAttempts($identifier); - $currentTime = $this->timeFactory->getTime(); - - // Unset all attempts older than $period - foreach ($existingAttempts as $key => $attempt) { - if (($attempt + $period) < $currentTime) { - unset($existingAttempts[$key]); - } - } - $existingAttempts = array_values($existingAttempts); - - // Store the new attempt - $existingAttempts[] = (string)$currentTime; - $this->cache->set($identifier, json_encode($existingAttempts)); - } -} diff --git a/lib/private/Security/RateLimiting/Backend/MemoryCacheBackend.php b/lib/private/Security/RateLimiting/Backend/MemoryCacheBackend.php new file mode 100644 index 00000000000..4c33b49d05e --- /dev/null +++ b/lib/private/Security/RateLimiting/Backend/MemoryCacheBackend.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\RateLimiting\Backend; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; + +/** + * Class MemoryCacheBackend uses the configured distributed memory cache for storing + * rate limiting data. + * + * @package OC\Security\RateLimiting\Backend + */ +class MemoryCacheBackend implements IBackend { + private ICache $cache; + + public function __construct( + private IConfig $config, + ICacheFactory $cacheFactory, + private ITimeFactory $timeFactory, + ) { + $this->cache = $cacheFactory->createDistributed(self::class); + } + + private function hash( + string $methodIdentifier, + string $userIdentifier, + ): string { + return hash('sha512', $methodIdentifier . $userIdentifier); + } + + private function getExistingAttempts(string $identifier): array { + $cachedAttempts = $this->cache->get($identifier); + if ($cachedAttempts === null) { + return []; + } + + $cachedAttempts = json_decode($cachedAttempts, true); + if (\is_array($cachedAttempts)) { + return $cachedAttempts; + } + + return []; + } + + /** + * {@inheritDoc} + */ + public function getAttempts( + string $methodIdentifier, + string $userIdentifier, + ): int { + $identifier = $this->hash($methodIdentifier, $userIdentifier); + $existingAttempts = $this->getExistingAttempts($identifier); + + $count = 0; + $currentTime = $this->timeFactory->getTime(); + foreach ($existingAttempts as $expirationTime) { + if ($expirationTime > $currentTime) { + $count++; + } + } + + return $count; + } + + /** + * {@inheritDoc} + */ + public function registerAttempt( + string $methodIdentifier, + string $userIdentifier, + int $period, + ): void { + $identifier = $this->hash($methodIdentifier, $userIdentifier); + $existingAttempts = $this->getExistingAttempts($identifier); + $currentTime = $this->timeFactory->getTime(); + + // Unset all attempts that are already expired + foreach ($existingAttempts as $key => $expirationTime) { + if ($expirationTime < $currentTime) { + unset($existingAttempts[$key]); + } + } + $existingAttempts = array_values($existingAttempts); + + // Store the new attempt + $existingAttempts[] = (string)($currentTime + $period); + + if (!$this->config->getSystemValueBool('ratelimit.protection.enabled', true)) { + return; + } + + $this->cache->set($identifier, json_encode($existingAttempts)); + } +} diff --git a/lib/private/Security/RateLimiting/Exception/RateLimitExceededException.php b/lib/private/Security/RateLimiting/Exception/RateLimitExceededException.php index 000056a322c..19defc2d896 100644 --- a/lib/private/Security/RateLimiting/Exception/RateLimitExceededException.php +++ b/lib/private/Security/RateLimiting/Exception/RateLimitExceededException.php @@ -3,33 +3,16 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch> - * - * @author Lukas Reschke <lukas@statuscode.ch> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Security\RateLimiting\Exception; use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; use OCP\AppFramework\Http; +use OCP\Security\RateLimiting\IRateLimitExceededException; -class RateLimitExceededException extends SecurityException { +class RateLimitExceededException extends SecurityException implements IRateLimitExceededException { public function __construct() { parent::__construct('Rate limit exceeded', Http::STATUS_TOO_MANY_REQUESTS); } diff --git a/lib/private/Security/RateLimiting/Limiter.php b/lib/private/Security/RateLimiting/Limiter.php index 26671f52301..316becfa009 100644 --- a/lib/private/Security/RateLimiting/Limiter.php +++ b/lib/private/Security/RateLimiting/Limiter.php @@ -3,84 +3,60 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch> - * - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Security\RateLimiting; use OC\Security\Normalizer\IpAddress; use OC\Security\RateLimiting\Backend\IBackend; use OC\Security\RateLimiting\Exception\RateLimitExceededException; -use OCP\AppFramework\Utility\ITimeFactory; use OCP\IUser; +use OCP\Security\RateLimiting\ILimiter; +use Psr\Log\LoggerInterface; -class Limiter { - /** @var IBackend */ - private $backend; - /** @var ITimeFactory */ - private $timeFactory; - - /** - * @param ITimeFactory $timeFactory - * @param IBackend $backend - */ - public function __construct(ITimeFactory $timeFactory, - IBackend $backend) { - $this->backend = $backend; - $this->timeFactory = $timeFactory; +class Limiter implements ILimiter { + public function __construct( + private IBackend $backend, + private LoggerInterface $logger, + ) { } /** - * @param string $methodIdentifier - * @param string $userIdentifier - * @param int $period - * @param int $limit + * @param int $period in seconds * @throws RateLimitExceededException */ - private function register(string $methodIdentifier, - string $userIdentifier, - int $period, - int $limit): void { - $existingAttempts = $this->backend->getAttempts($methodIdentifier, $userIdentifier, $period); + private function register( + string $methodIdentifier, + string $userIdentifier, + int $period, + int $limit, + ): void { + $existingAttempts = $this->backend->getAttempts($methodIdentifier, $userIdentifier); if ($existingAttempts >= $limit) { + $this->logger->info('Request blocked because it exceeds the rate limit [method: {method}, limit: {limit}, period: {period}]', [ + 'method' => $methodIdentifier, + 'limit' => $limit, + 'period' => $period, + ]); throw new RateLimitExceededException(); } - $this->backend->registerAttempt($methodIdentifier, $userIdentifier, $this->timeFactory->getTime()); + $this->backend->registerAttempt($methodIdentifier, $userIdentifier, $period); } /** * Registers attempt for an anonymous request * - * @param string $identifier - * @param int $anonLimit - * @param int $anonPeriod - * @param string $ip + * @param int $anonPeriod in seconds * @throws RateLimitExceededException */ - public function registerAnonRequest(string $identifier, - int $anonLimit, - int $anonPeriod, - string $ip): void { + public function registerAnonRequest( + string $identifier, + int $anonLimit, + int $anonPeriod, + string $ip, + ): void { $ipSubnet = (new IpAddress($ip))->getSubnet(); $anonHashIdentifier = hash('sha512', 'anon::' . $identifier . $ipSubnet); @@ -90,16 +66,15 @@ class Limiter { /** * Registers attempt for an authenticated request * - * @param string $identifier - * @param int $userLimit - * @param int $userPeriod - * @param IUser $user + * @param int $userPeriod in seconds * @throws RateLimitExceededException */ - public function registerUserRequest(string $identifier, - int $userLimit, - int $userPeriod, - IUser $user): void { + public function registerUserRequest( + string $identifier, + int $userLimit, + int $userPeriod, + IUser $user, + ): void { $userHashIdentifier = hash('sha512', 'user::' . $identifier . $user->getUID()); $this->register($identifier, $userHashIdentifier, $userPeriod, $userLimit); } |