diff options
Diffstat (limited to 'lib/private/Security/RateLimiting')
5 files changed, 345 insertions, 0 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 new file mode 100644 index 00000000000..43eff5dcf02 --- /dev/null +++ b/lib/private/Security/RateLimiting/Backend/IBackend.php @@ -0,0 +1,42 @@ +<?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; + +/** + * Interface IBackend defines a storage backend for the rate limiting data. It + * should be noted that writing and reading rate limiting data is an expensive + * operation and one should thus make sure to only use sufficient fast backends. + * + * @package OC\Security\RateLimiting\Backend + */ +interface IBackend { + /** + * Gets the number of attempts for the specified method + * + * @param string $methodIdentifier Identifier for the method + * @param string $userIdentifier Identifier for the user + */ + public function getAttempts( + string $methodIdentifier, + string $userIdentifier, + ): int; + + /** + * Registers an attempt + * + * @param string $methodIdentifier Identifier for the method + * @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, + ); +} 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 new file mode 100644 index 00000000000..19defc2d896 --- /dev/null +++ b/lib/private/Security/RateLimiting/Exception/RateLimitExceededException.php @@ -0,0 +1,19 @@ +<?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\Exception; + +use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; +use OCP\AppFramework\Http; +use OCP\Security\RateLimiting\IRateLimitExceededException; + +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 new file mode 100644 index 00000000000..316becfa009 --- /dev/null +++ b/lib/private/Security/RateLimiting/Limiter.php @@ -0,0 +1,81 @@ +<?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; + +use OC\Security\Normalizer\IpAddress; +use OC\Security\RateLimiting\Backend\IBackend; +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OCP\IUser; +use OCP\Security\RateLimiting\ILimiter; +use Psr\Log\LoggerInterface; + +class Limiter implements ILimiter { + public function __construct( + private IBackend $backend, + private LoggerInterface $logger, + ) { + } + + /** + * @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); + 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, $period); + } + + /** + * Registers attempt for an anonymous request + * + * @param int $anonPeriod in seconds + * @throws RateLimitExceededException + */ + public function registerAnonRequest( + string $identifier, + int $anonLimit, + int $anonPeriod, + string $ip, + ): void { + $ipSubnet = (new IpAddress($ip))->getSubnet(); + + $anonHashIdentifier = hash('sha512', 'anon::' . $identifier . $ipSubnet); + $this->register($identifier, $anonHashIdentifier, $anonPeriod, $anonLimit); + } + + /** + * Registers attempt for an authenticated request + * + * @param int $userPeriod in seconds + * @throws RateLimitExceededException + */ + 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); + } +} |