aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/Security/RateLimiting
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/Security/RateLimiting')
-rw-r--r--lib/private/Security/RateLimiting/Backend/DatabaseBackend.php99
-rw-r--r--lib/private/Security/RateLimiting/Backend/IBackend.php42
-rw-r--r--lib/private/Security/RateLimiting/Backend/MemoryCacheBackend.php104
-rw-r--r--lib/private/Security/RateLimiting/Exception/RateLimitExceededException.php19
-rw-r--r--lib/private/Security/RateLimiting/Limiter.php81
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);
+ }
+}