diff options
Diffstat (limited to 'lib/private/Security/Bruteforce/Backend')
3 files changed, 308 insertions, 0 deletions
diff --git a/lib/private/Security/Bruteforce/Backend/DatabaseBackend.php b/lib/private/Security/Bruteforce/Backend/DatabaseBackend.php new file mode 100644 index 00000000000..33c2a3aae62 --- /dev/null +++ b/lib/private/Security/Bruteforce/Backend/DatabaseBackend.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\Bruteforce\Backend; + +use OCP\IDBConnection; + +class DatabaseBackend implements IBackend { + private const TABLE_NAME = 'bruteforce_attempts'; + + public function __construct( + private IDBConnection $db, + ) { + } + + /** + * {@inheritDoc} + */ + public function getAttempts( + string $ipSubnet, + int $maxAgeTimestamp, + ?string $action = null, + ?array $metadata = null, + ): int { + $query = $this->db->getQueryBuilder(); + $query->select($query->func()->count('*', 'attempts')) + ->from(self::TABLE_NAME) + ->where($query->expr()->gt('occurred', $query->createNamedParameter($maxAgeTimestamp))) + ->andWhere($query->expr()->eq('subnet', $query->createNamedParameter($ipSubnet))); + + if ($action !== null) { + $query->andWhere($query->expr()->eq('action', $query->createNamedParameter($action))); + + if ($metadata !== null) { + $query->andWhere($query->expr()->eq('metadata', $query->createNamedParameter(json_encode($metadata)))); + } + } + + $result = $query->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + return (int)$row['attempts']; + } + + /** + * {@inheritDoc} + */ + public function resetAttempts( + string $ipSubnet, + ?string $action = null, + ?array $metadata = null, + ): void { + $query = $this->db->getQueryBuilder(); + $query->delete(self::TABLE_NAME) + ->where($query->expr()->eq('subnet', $query->createNamedParameter($ipSubnet))); + + if ($action !== null) { + $query->andWhere($query->expr()->eq('action', $query->createNamedParameter($action))); + + if ($metadata !== null) { + $query->andWhere($query->expr()->eq('metadata', $query->createNamedParameter(json_encode($metadata)))); + } + } + + $query->executeStatement(); + } + + /** + * {@inheritDoc} + */ + public function registerAttempt( + string $ip, + string $ipSubnet, + int $timestamp, + string $action, + array $metadata = [], + ): void { + $values = [ + 'ip' => $ip, + 'subnet' => $ipSubnet, + 'action' => $action, + 'metadata' => json_encode($metadata), + 'occurred' => $timestamp, + ]; + + $qb = $this->db->getQueryBuilder(); + $qb->insert(self::TABLE_NAME); + foreach ($values as $column => $value) { + $qb->setValue($column, $qb->createNamedParameter($value)); + } + $qb->executeStatement(); + } +} diff --git a/lib/private/Security/Bruteforce/Backend/IBackend.php b/lib/private/Security/Bruteforce/Backend/IBackend.php new file mode 100644 index 00000000000..7118123cbb5 --- /dev/null +++ b/lib/private/Security/Bruteforce/Backend/IBackend.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\Bruteforce\Backend; + +/** + * Interface IBackend defines a storage backend for the bruteforce data. It + * should be noted that writing and reading brute force data is an expensive + * operation and one should thus make sure to only use sufficient fast backends. + */ +interface IBackend { + /** + * Gets the number of attempts for the specified subnet (and further filters) + * + * @param string $ipSubnet + * @param int $maxAgeTimestamp + * @param ?string $action Optional action to further limit attempts + * @param ?array $metadata Optional metadata stored to further limit attempts (Only considered when $action is set) + * @return int + * @since 28.0.0 + */ + public function getAttempts( + string $ipSubnet, + int $maxAgeTimestamp, + ?string $action = null, + ?array $metadata = null, + ): int; + + /** + * Reset the attempts for the specified subnet (and further filters) + * + * @param string $ipSubnet + * @param ?string $action Optional action to further limit attempts + * @param ?array $metadata Optional metadata stored to further limit attempts(Only considered when $action is set) + * @since 28.0.0 + */ + public function resetAttempts( + string $ipSubnet, + ?string $action = null, + ?array $metadata = null, + ): void; + + /** + * Register a failed attempt to bruteforce a security control + * + * @param string $ip + * @param string $ipSubnet + * @param int $timestamp + * @param string $action + * @param array $metadata Optional metadata stored to further limit attempts when getting + * @since 28.0.0 + */ + public function registerAttempt( + string $ip, + string $ipSubnet, + int $timestamp, + string $action, + array $metadata = [], + ): void; +} diff --git a/lib/private/Security/Bruteforce/Backend/MemoryCacheBackend.php b/lib/private/Security/Bruteforce/Backend/MemoryCacheBackend.php new file mode 100644 index 00000000000..9a0723db47e --- /dev/null +++ b/lib/private/Security/Bruteforce/Backend/MemoryCacheBackend.php @@ -0,0 +1,144 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\Bruteforce\Backend; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\ICache; +use OCP\ICacheFactory; + +class MemoryCacheBackend implements IBackend { + private ICache $cache; + + public function __construct( + ICacheFactory $cacheFactory, + private ITimeFactory $timeFactory, + ) { + $this->cache = $cacheFactory->createDistributed(self::class); + } + + private function hash( + null|string|array $data, + ): ?string { + if ($data === null) { + return null; + } + if (!is_string($data)) { + $data = json_encode($data); + } + return hash('sha1', $data); + } + + 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 $ipSubnet, + int $maxAgeTimestamp, + ?string $action = null, + ?array $metadata = null, + ): int { + $identifier = $this->hash($ipSubnet); + $actionHash = $this->hash($action); + $metadataHash = $this->hash($metadata); + $existingAttempts = $this->getExistingAttempts($identifier); + + $count = 0; + foreach ($existingAttempts as $info) { + [$occurredTime, $attemptAction, $attemptMetadata] = explode('#', $info, 3); + if ($action === null || $attemptAction === $actionHash) { + if ($metadata === null || $attemptMetadata === $metadataHash) { + if ($occurredTime > $maxAgeTimestamp) { + $count++; + } + } + } + } + + return $count; + } + + /** + * {@inheritDoc} + */ + public function resetAttempts( + string $ipSubnet, + ?string $action = null, + ?array $metadata = null, + ): void { + $identifier = $this->hash($ipSubnet); + if ($action === null) { + $this->cache->remove($identifier); + } else { + $actionHash = $this->hash($action); + $metadataHash = $this->hash($metadata); + $existingAttempts = $this->getExistingAttempts($identifier); + $maxAgeTimestamp = $this->timeFactory->getTime() - 12 * 3600; + + foreach ($existingAttempts as $key => $info) { + [$occurredTime, $attemptAction, $attemptMetadata] = explode('#', $info, 3); + if ($attemptAction === $actionHash) { + if ($metadata === null || $attemptMetadata === $metadataHash) { + unset($existingAttempts[$key]); + } elseif ($occurredTime < $maxAgeTimestamp) { + unset($existingAttempts[$key]); + } + } + } + + if (!empty($existingAttempts)) { + $this->cache->set($identifier, json_encode($existingAttempts), 12 * 3600); + } else { + $this->cache->remove($identifier); + } + } + } + + /** + * {@inheritDoc} + */ + public function registerAttempt( + string $ip, + string $ipSubnet, + int $timestamp, + string $action, + array $metadata = [], + ): void { + $identifier = $this->hash($ipSubnet); + $existingAttempts = $this->getExistingAttempts($identifier); + $maxAgeTimestamp = $this->timeFactory->getTime() - 12 * 3600; + + // Unset all attempts that are already expired + foreach ($existingAttempts as $key => $info) { + [$occurredTime,] = explode('#', $info, 3); + if ($occurredTime < $maxAgeTimestamp) { + unset($existingAttempts[$key]); + } + } + $existingAttempts = array_values($existingAttempts); + + // Store the new attempt + $existingAttempts[] = $timestamp . '#' . $this->hash($action) . '#' . $this->hash($metadata); + + $this->cache->set($identifier, json_encode($existingAttempts), 12 * 3600); + } +} |