diff options
Diffstat (limited to 'lib/private/Security')
45 files changed, 3014 insertions, 1574 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); + } +} diff --git a/lib/private/Security/Bruteforce/Capabilities.php b/lib/private/Security/Bruteforce/Capabilities.php index 7547348ce34..581b4480a27 100644 --- a/lib/private/Security/Bruteforce/Capabilities.php +++ b/lib/private/Security/Bruteforce/Capabilities.php @@ -1,60 +1,34 @@ <?php + +declare(strict_types=1); + /** - * @copyright Copyright (c) 2017 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Julius Härtl <jus@bitgrid.net> - * @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\Bruteforce; +use OCP\Capabilities\IInitialStateExcludedCapability; use OCP\Capabilities\IPublicCapability; use OCP\IRequest; +use OCP\Security\Bruteforce\IThrottler; -class Capabilities implements IPublicCapability { - /** @var IRequest */ - private $request; - - /** @var Throttler */ - private $throttler; +class Capabilities implements IPublicCapability, IInitialStateExcludedCapability { + public function __construct( + private IRequest $request, + private IThrottler $throttler, + ) { + } /** - * Capabilities constructor. - * - * @param IRequest $request - * @param Throttler $throttler + * @return array{bruteforce: array{delay: int, allow-listed: bool}} */ - public function __construct(IRequest $request, - Throttler $throttler) { - $this->request = $request; - $this->throttler = $throttler; - } - - public function getCapabilities() { - if (version_compare(\OC::$server->getConfig()->getSystemValue('version', '0.0.0.0'), '12.0.0.0', '<')) { - return []; - } - + public function getCapabilities(): array { return [ 'bruteforce' => [ - 'delay' => $this->throttler->getDelay($this->request->getRemoteAddress()) - ] + 'delay' => $this->throttler->getDelay($this->request->getRemoteAddress()), + 'allow-listed' => $this->throttler->isBypassListed($this->request->getRemoteAddress()), + ], ]; } } diff --git a/lib/private/Security/Bruteforce/CleanupJob.php b/lib/private/Security/Bruteforce/CleanupJob.php index edf59cdcdc5..f07e4dbacbd 100644 --- a/lib/private/Security/Bruteforce/CleanupJob.php +++ b/lib/private/Security/Bruteforce/CleanupJob.php @@ -3,27 +3,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl> - * - * @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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Security\Bruteforce; use OCP\AppFramework\Utility\ITimeFactory; @@ -32,25 +14,24 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; class CleanupJob extends TimedJob { - - /** @var IDBConnection */ - private $connection; - - public function __construct(ITimeFactory $time, IDBConnection $connection) { + public function __construct( + ITimeFactory $time, + private IDBConnection $connection, + ) { parent::__construct($time); - $this->connection = $connection; // Run once a day - $this->setInterval(3600 * 24); + $this->setInterval(60 * 60 * 24); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); } - protected function run($argument) { + protected function run($argument): void { // Delete all entries more than 48 hours old $time = $this->time->getTime() - (48 * 3600); $qb = $this->connection->getQueryBuilder(); $qb->delete('bruteforce_attempts') ->where($qb->expr()->lt('occurred', $qb->createNamedParameter($time), IQueryBuilder::PARAM_INT)); - $qb->execute(); + $qb->executeStatement(); } } diff --git a/lib/private/Security/Bruteforce/Throttler.php b/lib/private/Security/Bruteforce/Throttler.php index e1d9127a7bb..574f6c80c3f 100644 --- a/lib/private/Security/Bruteforce/Throttler.php +++ b/lib/private/Security/Bruteforce/Throttler.php @@ -3,42 +3,19 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch> - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Johannes Riedel <joeried@users.noreply.github.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @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: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Security\Bruteforce; +use OC\Security\Bruteforce\Backend\IBackend; +use OC\Security\Ip\BruteforceAllowList; use OC\Security\Normalizer\IpAddress; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; -use OCP\IDBConnection; -use OCP\ILogger; +use OCP\Security\Bruteforce\IThrottler; use OCP\Security\Bruteforce\MaxDelayReached; +use Psr\Log\LoggerInterface; /** * Class Throttler implements the bruteforce protection for security actions in @@ -53,85 +30,34 @@ use OCP\Security\Bruteforce\MaxDelayReached; * * @package OC\Security\Bruteforce */ -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; - /** @var ITimeFactory */ - private $timeFactory; - /** @var ILogger */ - private $logger; - /** @var IConfig */ - private $config; - - /** - * @param IDBConnection $db - * @param ITimeFactory $timeFactory - * @param ILogger $logger - * @param IConfig $config - */ - public function __construct(IDBConnection $db, - ITimeFactory $timeFactory, - ILogger $logger, - IConfig $config) { - $this->db = $db; - $this->timeFactory = $timeFactory; - $this->logger = $logger; - $this->config = $config; +class Throttler implements IThrottler { + /** @var bool[] */ + private array $hasAttemptsDeleted = []; + + public function __construct( + private ITimeFactory $timeFactory, + private LoggerInterface $logger, + private IConfig $config, + private IBackend $backend, + private BruteforceAllowList $allowList, + ) { } /** - * Convert a number of seconds into the appropriate DateInterval - * - * @param int $expire - * @return \DateInterval - */ - private function getCutoff(int $expire): \DateInterval { - $d1 = new \DateTime(); - $d2 = clone $d1; - $d2->sub(new \DateInterval('PT' . $expire . 'S')); - return $d2->diff($d1); - } - - /** - * Calculate the cut off timestamp - * - * @param float $maxAgeHours - * @return int - */ - private function getCutoffTimestamp(float $maxAgeHours = 12.0): int { - return (new \DateTime()) - ->sub($this->getCutoff((int) ($maxAgeHours * 3600))) - ->getTimestamp(); - } - - /** - * Register a failed attempt to bruteforce a security control - * - * @param string $action - * @param string $ip - * @param array $metadata Optional metadata logged to the database + * {@inheritDoc} */ public function registerAttempt(string $action, - string $ip, - array $metadata = []): void { + 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) { + if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) { return; } $ipAddress = new IpAddress($ip); - $values = [ - 'action' => $action, - 'occurred' => $this->timeFactory->getTime(), - 'ip' => (string)$ipAddress, - 'subnet' => $ipAddress->getSubnet(), - 'metadata' => json_encode($metadata), - ]; + if ($this->isBypassListed((string)$ipAddress)) { + return; + } $this->logger->notice( sprintf( @@ -144,86 +70,33 @@ class Throttler { ] ); - $qb = $this->db->getQueryBuilder(); - $qb->insert('bruteforce_attempts'); - foreach ($values as $column => $value) { - $qb->setValue($column, $qb->createNamedParameter($value)); - } - $qb->execute(); + $this->backend->registerAttempt( + (string)$ipAddress, + $ipAddress->getSubnet(), + $this->timeFactory->getTime(), + $action, + $metadata + ); } /** * Check if the IP is whitelisted - * - * @param string $ip - * @return bool */ - 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) { - return 0 === strpos($key, 'whitelist_'); - }); - - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $type = 4; - } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - $type = 6; - } else { - return false; - } - - $ip = inet_pton($ip); - - foreach ($keys as $key) { - $cidr = $this->config->getAppValue('bruteForce', $key, null); - - $cx = explode('/', $cidr); - $addr = $cx[0]; - $mask = (int)$cx[1]; - - // Do not compare ipv4 to ipv6 - if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) || - ($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) { - continue; - } - - $addr = inet_pton($addr); - - $valid = true; - for ($i = 0; $i < $mask; $i++) { - $part = ord($addr[(int)($i / 8)]); - $orig = ord($ip[(int)($i / 8)]); - - $bitmask = 1 << (7 - ($i % 8)); - - $part = $part & $bitmask; - $orig = $orig & $bitmask; - - if ($part !== $orig) { - $valid = false; - break; - } - } - - if ($valid === true) { - return true; - } - } + public function isBypassListed(string $ip): bool { + return $this->allowList->isBypassListed($ip); + } - return false; + /** + * {@inheritDoc} + */ + public function showBruteforceWarning(string $ip, string $action = ''): bool { + $attempts = $this->getAttempts($ip, $action); + // 4 failed attempts is the last delay below 5 seconds + return $attempts >= 4; } /** - * Get the throttling delay (in milliseconds) - * - * @param string $ip - * @param string $action optionally filter by action - * @param float $maxAgeHours - * @return int + * {@inheritDoc} */ public function getAttempts(string $ip, string $action = '', float $maxAgeHours = 12): int { if ($maxAgeHours > 48) { @@ -231,49 +104,42 @@ class Throttler { $maxAgeHours = 48; } - if ($ip === '') { + if ($ip === '' || isset($this->hasAttemptsDeleted[$action])) { return 0; } $ipAddress = new IpAddress($ip); - if ($this->isIPWhitelisted((string)$ipAddress)) { + if ($this->isBypassListed((string)$ipAddress)) { return 0; } - $cutoffTime = $this->getCutoffTimestamp($maxAgeHours); - - $qb = $this->db->getQueryBuilder(); - $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()))); - - if ($action !== '') { - $qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action))); - } - - $result = $qb->execute(); - $row = $result->fetch(); - $result->closeCursor(); + $maxAgeTimestamp = (int)($this->timeFactory->getTime() - 3600 * $maxAgeHours); - return (int) $row['attempts']; + return $this->backend->getAttempts( + $ipAddress->getSubnet(), + $maxAgeTimestamp, + $action !== '' ? $action : null, + ); } /** - * Get the throttling delay (in milliseconds) - * - * @param string $ip - * @param string $action optionally filter by action - * @return int + * {@inheritDoc} */ public function getDelay(string $ip, string $action = ''): int { $attempts = $this->getAttempts($ip, $action); + return $this->calculateDelay($attempts); + } + + /** + * {@inheritDoc} + */ + public function calculateDelay(int $attempts): int { if ($attempts === 0) { return 0; } $firstDelay = 0.1; - if ($attempts > self::MAX_ATTEMPTS) { + if ($attempts > $this->config->getSystemValueInt('auth.bruteforce.max-attempts', self::MAX_ATTEMPTS)) { // Don't ever overflow. Just assume the maxDelay time:s return self::MAX_DELAY_MS; } @@ -282,79 +148,89 @@ class Throttler { if ($delay > self::MAX_DELAY) { return self::MAX_DELAY_MS; } - return (int) \ceil($delay * 1000); + return (int)\ceil($delay * 1000); } /** - * Reset the throttling delay for an IP address, action and metadata - * - * @param string $ip - * @param string $action - * @param array $metadata + * {@inheritDoc} */ public function resetDelay(string $ip, string $action, array $metadata): void { - $ipAddress = new IpAddress($ip); - if ($this->isIPWhitelisted((string)$ipAddress)) { + // No need to log if the bruteforce protection is disabled + if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) { return; } - $cutoffTime = $this->getCutoffTimestamp(); + $ipAddress = new IpAddress($ip); + if ($this->isBypassListed((string)$ipAddress)) { + return; + } - $qb = $this->db->getQueryBuilder(); - $qb->delete('bruteforce_attempts') - ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime))) - ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet()))) - ->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action))) - ->andWhere($qb->expr()->eq('metadata', $qb->createNamedParameter(json_encode($metadata)))); + $this->backend->resetAttempts( + $ipAddress->getSubnet(), + $action, + $metadata, + ); - $qb->execute(); + $this->hasAttemptsDeleted[$action] = true; } /** - * Reset the throttling delay for an IP address - * - * @param string $ip + * {@inheritDoc} */ - public function resetDelayForIP($ip) { - $cutoffTime = $this->getCutoffTimestamp(); + public function resetDelayForIP(string $ip): void { + // No need to log if the bruteforce protection is disabled + if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) { + return; + } - $qb = $this->db->getQueryBuilder(); - $qb->delete('bruteforce_attempts') - ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime))) - ->andWhere($qb->expr()->eq('ip', $qb->createNamedParameter($ip))); + $ipAddress = new IpAddress($ip); + if ($this->isBypassListed((string)$ipAddress)) { + return; + } - $qb->execute(); + $this->backend->resetAttempts($ipAddress->getSubnet()); } /** - * Will sleep for the defined amount of time - * - * @param string $ip - * @param string $action optionally filter by action - * @return int the time spent sleeping + * {@inheritDoc} */ public function sleepDelay(string $ip, string $action = ''): int { $delay = $this->getDelay($ip, $action); - usleep($delay * 1000); + if (!$this->config->getSystemValueBool('auth.bruteforce.protection.testing')) { + 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 + * {@inheritDoc} */ 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'); + $maxAttempts = $this->config->getSystemValueInt('auth.bruteforce.max-attempts', self::MAX_ATTEMPTS); + $attempts = $this->getAttempts($ip, $action); + if ($attempts > $maxAttempts) { + $attempts30mins = $this->getAttempts($ip, $action, 0.5); + if ($attempts30mins > $maxAttempts) { + $this->logger->info('IP address blocked because it reached the maximum failed attempts in the last 30 minutes [action: {action}, attempts: {attempts}, ip: {ip}]', [ + 'action' => $action, + 'ip' => $ip, + 'attempts' => $attempts30mins, + ]); + // If the ip made too many attempts within the last 30 mins we don't execute anymore + throw new MaxDelayReached('Reached maximum delay'); + } + + $this->logger->info('IP address throttled because it reached the attempts limit in the last 12 hours [action: {action}, attempts: {attempts}, ip: {ip}]', [ + 'action' => $action, + 'ip' => $ip, + 'attempts' => $attempts, + ]); } - usleep($delay * 1000); - return $delay; + + if ($attempts > 0) { + return $this->calculateDelay($attempts); + } + + return 0; } } diff --git a/lib/private/Security/CSP/ContentSecurityPolicy.php b/lib/private/Security/CSP/ContentSecurityPolicy.php index 4d41bd56206..890251db040 100644 --- a/lib/private/Security/CSP/ContentSecurityPolicy.php +++ b/lib/private/Security/CSP/ContentSecurityPolicy.php @@ -1,30 +1,11 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security\CSP; /** @@ -35,164 +16,106 @@ namespace OC\Security\CSP; * @package OC\Security\CSP */ class ContentSecurityPolicy extends \OCP\AppFramework\Http\ContentSecurityPolicy { - /** - * @return boolean - */ public function isInlineScriptAllowed(): bool { return $this->inlineScriptAllowed; } - /** - * @param boolean $inlineScriptAllowed - */ - public function setInlineScriptAllowed(bool $inlineScriptAllowed) { + public function setInlineScriptAllowed(bool $inlineScriptAllowed): void { $this->inlineScriptAllowed = $inlineScriptAllowed; } - /** - * @return boolean - */ public function isEvalScriptAllowed(): bool { return $this->evalScriptAllowed; } /** - * @param boolean $evalScriptAllowed - * * @deprecated 17.0.0 Unsafe eval should not be used anymore. */ - public function setEvalScriptAllowed(bool $evalScriptAllowed) { + public function setEvalScriptAllowed(bool $evalScriptAllowed): void { $this->evalScriptAllowed = $evalScriptAllowed; } - /** - * @return array - */ + public function isEvalWasmAllowed(): ?bool { + return $this->evalWasmAllowed; + } + + public function setEvalWasmAllowed(bool $evalWasmAllowed): void { + $this->evalWasmAllowed = $evalWasmAllowed; + } + public function getAllowedScriptDomains(): array { return $this->allowedScriptDomains; } - /** - * @param array $allowedScriptDomains - */ - public function setAllowedScriptDomains(array $allowedScriptDomains) { + public function setAllowedScriptDomains(array $allowedScriptDomains): void { $this->allowedScriptDomains = $allowedScriptDomains; } - /** - * @return boolean - */ public function isInlineStyleAllowed(): bool { return $this->inlineStyleAllowed; } - /** - * @param boolean $inlineStyleAllowed - */ - public function setInlineStyleAllowed(bool $inlineStyleAllowed) { + public function setInlineStyleAllowed(bool $inlineStyleAllowed): void { $this->inlineStyleAllowed = $inlineStyleAllowed; } - /** - * @return array - */ public function getAllowedStyleDomains(): array { return $this->allowedStyleDomains; } - /** - * @param array $allowedStyleDomains - */ - public function setAllowedStyleDomains(array $allowedStyleDomains) { + public function setAllowedStyleDomains(array $allowedStyleDomains): void { $this->allowedStyleDomains = $allowedStyleDomains; } - /** - * @return array - */ public function getAllowedImageDomains(): array { return $this->allowedImageDomains; } - /** - * @param array $allowedImageDomains - */ - public function setAllowedImageDomains(array $allowedImageDomains) { + public function setAllowedImageDomains(array $allowedImageDomains): void { $this->allowedImageDomains = $allowedImageDomains; } - /** - * @return array - */ public function getAllowedConnectDomains(): array { return $this->allowedConnectDomains; } - /** - * @param array $allowedConnectDomains - */ - public function setAllowedConnectDomains(array $allowedConnectDomains) { + public function setAllowedConnectDomains(array $allowedConnectDomains): void { $this->allowedConnectDomains = $allowedConnectDomains; } - /** - * @return array - */ public function getAllowedMediaDomains(): array { return $this->allowedMediaDomains; } - /** - * @param array $allowedMediaDomains - */ - public function setAllowedMediaDomains(array $allowedMediaDomains) { + public function setAllowedMediaDomains(array $allowedMediaDomains): void { $this->allowedMediaDomains = $allowedMediaDomains; } - /** - * @return array - */ public function getAllowedObjectDomains(): array { return $this->allowedObjectDomains; } - /** - * @param array $allowedObjectDomains - */ - public function setAllowedObjectDomains(array $allowedObjectDomains) { + public function setAllowedObjectDomains(array $allowedObjectDomains): void { $this->allowedObjectDomains = $allowedObjectDomains; } - /** - * @return array - */ public function getAllowedFrameDomains(): array { return $this->allowedFrameDomains; } - /** - * @param array $allowedFrameDomains - */ - public function setAllowedFrameDomains(array $allowedFrameDomains) { + public function setAllowedFrameDomains(array $allowedFrameDomains): void { $this->allowedFrameDomains = $allowedFrameDomains; } - /** - * @return array - */ public function getAllowedFontDomains(): array { return $this->allowedFontDomains; } - /** - * @param array $allowedFontDomains - */ - public function setAllowedFontDomains($allowedFontDomains) { + public function setAllowedFontDomains($allowedFontDomains): void { $this->allowedFontDomains = $allowedFontDomains; } /** - * @return array * @deprecated 15.0.0 use FrameDomains and WorkerSrcDomains */ public function getAllowedChildSrcDomains(): array { @@ -203,13 +126,10 @@ class ContentSecurityPolicy extends \OCP\AppFramework\Http\ContentSecurityPolicy * @param array $allowedChildSrcDomains * @deprecated 15.0.0 use FrameDomains and WorkerSrcDomains */ - public function setAllowedChildSrcDomains($allowedChildSrcDomains) { + public function setAllowedChildSrcDomains($allowedChildSrcDomains): void { $this->allowedChildSrcDomains = $allowedChildSrcDomains; } - /** - * @return array - */ public function getAllowedFrameAncestors(): array { return $this->allowedFrameAncestors; } @@ -217,7 +137,7 @@ class ContentSecurityPolicy extends \OCP\AppFramework\Http\ContentSecurityPolicy /** * @param array $allowedFrameAncestors */ - public function setAllowedFrameAncestors($allowedFrameAncestors) { + public function setAllowedFrameAncestors($allowedFrameAncestors): void { $this->allowedFrameAncestors = $allowedFrameAncestors; } @@ -225,7 +145,7 @@ class ContentSecurityPolicy extends \OCP\AppFramework\Http\ContentSecurityPolicy return $this->allowedWorkerSrcDomains; } - public function setAllowedWorkerSrcDomains(array $allowedWorkerSrcDomains) { + public function setAllowedWorkerSrcDomains(array $allowedWorkerSrcDomains): void { $this->allowedWorkerSrcDomains = $allowedWorkerSrcDomains; } @@ -242,7 +162,23 @@ class ContentSecurityPolicy extends \OCP\AppFramework\Http\ContentSecurityPolicy return $this->reportTo; } - public function setReportTo(array $reportTo) { + public function setReportTo(array $reportTo): void { $this->reportTo = $reportTo; } + + public function isStrictDynamicAllowed(): bool { + return $this->strictDynamicAllowed; + } + + public function setStrictDynamicAllowed(bool $strictDynamicAllowed): void { + $this->strictDynamicAllowed = $strictDynamicAllowed; + } + + public function isStrictDynamicAllowedOnScripts(): bool { + return $this->strictDynamicAllowedOnScripts; + } + + public function setStrictDynamicAllowedOnScripts(bool $strictDynamicAllowedOnScripts): void { + $this->strictDynamicAllowedOnScripts = $strictDynamicAllowedOnScripts; + } } diff --git a/lib/private/Security/CSP/ContentSecurityPolicyManager.php b/lib/private/Security/CSP/ContentSecurityPolicyManager.php index 60a176cbd8b..e9d6b2945a8 100644 --- a/lib/private/Security/CSP/ContentSecurityPolicyManager.php +++ b/lib/private/Security/CSP/ContentSecurityPolicyManager.php @@ -3,29 +3,10 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @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 AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security\CSP; use OCP\AppFramework\Http\ContentSecurityPolicy; @@ -36,25 +17,21 @@ use OCP\Security\IContentSecurityPolicyManager; class ContentSecurityPolicyManager implements IContentSecurityPolicyManager { /** @var ContentSecurityPolicy[] */ - private $policies = []; - - /** @var IEventDispatcher */ - private $dispatcher; + private array $policies = []; - public function __construct(IEventDispatcher $dispatcher) { - $this->dispatcher = $dispatcher; + public function __construct( + private IEventDispatcher $dispatcher, + ) { } /** {@inheritdoc} */ - public function addDefaultPolicy(EmptyContentSecurityPolicy $policy) { + public function addDefaultPolicy(EmptyContentSecurityPolicy $policy): void { $this->policies[] = $policy; } /** * Get the configured default policy. This is not in the public namespace * as it is only supposed to be used by core itself. - * - * @return ContentSecurityPolicy */ public function getDefaultPolicy(): ContentSecurityPolicy { $event = new AddContentSecurityPolicyEvent($this); @@ -69,21 +46,24 @@ class ContentSecurityPolicyManager implements IContentSecurityPolicyManager { /** * Merges the first given policy with the second one - * - * @param ContentSecurityPolicy $defaultPolicy - * @param EmptyContentSecurityPolicy $originalPolicy - * @return ContentSecurityPolicy */ - public function mergePolicies(ContentSecurityPolicy $defaultPolicy, - EmptyContentSecurityPolicy $originalPolicy): ContentSecurityPolicy { + public function mergePolicies( + ContentSecurityPolicy $defaultPolicy, + EmptyContentSecurityPolicy $originalPolicy, + ): ContentSecurityPolicy { foreach ((object)(array)$originalPolicy as $name => $value) { - $setter = 'set'.ucfirst($name); + $setter = 'set' . ucfirst($name); if (\is_array($value)) { - $getter = 'get'.ucfirst($name); + $getter = 'get' . ucfirst($name); $currentValues = \is_array($defaultPolicy->$getter()) ? $defaultPolicy->$getter() : []; $defaultPolicy->$setter(array_values(array_unique(array_merge($currentValues, $value)))); } elseif (\is_bool($value)) { - $defaultPolicy->$setter($value); + $getter = 'is' . ucfirst($name); + $currentValue = $defaultPolicy->$getter(); + // true wins over false + if ($value > $currentValue) { + $defaultPolicy->$setter($value); + } } } diff --git a/lib/private/Security/CSP/ContentSecurityPolicyNonceManager.php b/lib/private/Security/CSP/ContentSecurityPolicyNonceManager.php index cc5a3c2d8fb..993f74ae0e4 100644 --- a/lib/private/Security/CSP/ContentSecurityPolicyNonceManager.php +++ b/lib/private/Security/CSP/ContentSecurityPolicyNonceManager.php @@ -3,32 +3,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Pavel Krasikov <klonishe@gmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sam Bull <aa6bs0@sambull.org> - * - * @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: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Security\CSP; use OC\AppFramework\Http\Request; @@ -39,32 +16,25 @@ use OCP\IRequest; * @package OC\Security\CSP */ class ContentSecurityPolicyNonceManager { - /** @var CsrfTokenManager */ - private $csrfTokenManager; - /** @var IRequest */ - private $request; - /** @var string */ - private $nonce = ''; + private string $nonce = ''; - /** - * @param CsrfTokenManager $csrfTokenManager - * @param IRequest $request - */ - public function __construct(CsrfTokenManager $csrfTokenManager, - IRequest $request) { - $this->csrfTokenManager = $csrfTokenManager; - $this->request = $request; + public function __construct( + private CsrfTokenManager $csrfTokenManager, + private IRequest $request, + ) { } /** - * Returns the current CSP nounce - * - * @return string + * Returns the current CSP nonce */ public function getNonce(): string { if ($this->nonce === '') { if (empty($this->request->server['CSP_NONCE'])) { - $this->nonce = base64_encode($this->csrfTokenManager->getToken()->getEncryptedValue()); + // Get the token from the CSRF token, we only use the "shared secret" part + // as the first part does not add any security / entropy to the token + // so it can be ignored to keep the nonce short while keeping the same randomness + $csrfSecret = explode(':', ($this->csrfTokenManager->getToken()->getEncryptedValue())); + $this->nonce = end($csrfSecret); } else { $this->nonce = $this->request->server['CSP_NONCE']; } @@ -75,22 +45,16 @@ class ContentSecurityPolicyNonceManager { /** * Check if the browser supports CSP v3 - * - * @return bool */ public function browserSupportsCspV3(): bool { - $browserWhitelist = [ - Request::USER_AGENT_CHROME, - // Firefox 45+ - '/^Mozilla\/5\.0 \([^)]+\) Gecko\/[0-9.]+ Firefox\/(4[5-9]|[5-9][0-9])\.[0-9.]+$/', - // Safari 12+ - '/^Mozilla\/5\.0 \([^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\) Version\/(?:1[2-9]|[2-9][0-9])\.[0-9]+(?:\.[0-9]+)? Safari\/[0-9.A-Z]+$/', + $browserBlocklist = [ + Request::USER_AGENT_IE, ]; - if ($this->request->isUserAgent($browserWhitelist)) { - return true; + if ($this->request->isUserAgent($browserBlocklist)) { + return false; } - return false; + return true; } } diff --git a/lib/private/Security/CSRF/CsrfToken.php b/lib/private/Security/CSRF/CsrfToken.php index 25ec8572a66..6aad0cd5944 100644 --- a/lib/private/Security/CSRF/CsrfToken.php +++ b/lib/private/Security/CSRF/CsrfToken.php @@ -1,31 +1,11 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Leon Klingele <git@leonklingele.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security\CSRF; /** @@ -37,23 +17,19 @@ namespace OC\Security\CSRF; * @package OC\Security\CSRF */ class CsrfToken { - /** @var string */ - private $value; - /** @var string */ - private $encryptedValue = ''; + private string $encryptedValue = ''; /** * @param string $value Value of the token. Can be encrypted or not encrypted. */ - public function __construct(string $value) { - $this->value = $value; + public function __construct( + private string $value, + ) { } /** * Encrypted value of the token. This is used to mitigate BREACH alike * vulnerabilities. For display measures do use this functionality. - * - * @return string */ public function getEncryptedValue(): string { if ($this->encryptedValue === '') { @@ -67,8 +43,6 @@ class CsrfToken { /** * The unencrypted value of the token. Used for decrypting an already * encrypted token. - * - * @return string */ public function getDecryptedValue(): string { $token = explode(':', $this->value); diff --git a/lib/private/Security/CSRF/CsrfTokenGenerator.php b/lib/private/Security/CSRF/CsrfTokenGenerator.php index 721c427c8c7..9e3f15c63d2 100644 --- a/lib/private/Security/CSRF/CsrfTokenGenerator.php +++ b/lib/private/Security/CSRF/CsrfTokenGenerator.php @@ -1,29 +1,11 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security\CSRF; use OCP\Security\ISecureRandom; @@ -35,21 +17,15 @@ use OCP\Security\ISecureRandom; * @package OC\Security\CSRF */ class CsrfTokenGenerator { - /** @var ISecureRandom */ - private $random; - - /** - * @param ISecureRandom $random - */ - public function __construct(ISecureRandom $random) { - $this->random = $random; + public function __construct( + private ISecureRandom $random, + ) { } /** * Generate a new CSRF token. * * @param int $length Length of the token in characters. - * @return string */ public function generateToken(int $length = 32): string { return $this->random->generate($length); diff --git a/lib/private/Security/CSRF/CsrfTokenManager.php b/lib/private/Security/CSRF/CsrfTokenManager.php index f0536c770b5..00e1be5bedf 100644 --- a/lib/private/Security/CSRF/CsrfTokenManager.php +++ b/lib/private/Security/CSRF/CsrfTokenManager.php @@ -1,30 +1,11 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security\CSRF; use OC\Security\CSRF\TokenStorage\SessionStorage; @@ -35,27 +16,18 @@ use OC\Security\CSRF\TokenStorage\SessionStorage; * @package OC\Security\CSRF */ class CsrfTokenManager { - /** @var CsrfTokenGenerator */ - private $tokenGenerator; - /** @var SessionStorage */ - private $sessionStorage; - /** @var CsrfToken|null */ - private $csrfToken = null; + private SessionStorage $sessionStorage; + private ?CsrfToken $csrfToken = null; - /** - * @param CsrfTokenGenerator $tokenGenerator - * @param SessionStorage $storageInterface - */ - public function __construct(CsrfTokenGenerator $tokenGenerator, - SessionStorage $storageInterface) { - $this->tokenGenerator = $tokenGenerator; + public function __construct( + private CsrfTokenGenerator $tokenGenerator, + SessionStorage $storageInterface, + ) { $this->sessionStorage = $storageInterface; } /** * Returns the current CSRF token, if none set it will create a new one. - * - * @return CsrfToken */ public function getToken(): CsrfToken { if (!\is_null($this->csrfToken)) { @@ -75,8 +47,6 @@ class CsrfTokenManager { /** * Invalidates any current token and sets a new one. - * - * @return CsrfToken */ public function refreshToken(): CsrfToken { $value = $this->tokenGenerator->generateToken(); @@ -88,16 +58,13 @@ class CsrfTokenManager { /** * Remove the current token from the storage. */ - public function removeToken() { + public function removeToken(): void { $this->csrfToken = null; $this->sessionStorage->removeToken(); } /** * Verifies whether the provided token is valid. - * - * @param CsrfToken $token - * @return bool */ public function isTokenValid(CsrfToken $token): bool { if (!$this->sessionStorage->hasToken()) { diff --git a/lib/private/Security/CSRF/TokenStorage/SessionStorage.php b/lib/private/Security/CSRF/TokenStorage/SessionStorage.php index ed9b068faa2..1f0f8bcaa0a 100644 --- a/lib/private/Security/CSRF/TokenStorage/SessionStorage.php +++ b/lib/private/Security/CSRF/TokenStorage/SessionStorage.php @@ -1,31 +1,11 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security\CSRF\TokenStorage; use OCP\ISession; @@ -36,27 +16,18 @@ use OCP\ISession; * @package OC\Security\CSRF\TokenStorage */ class SessionStorage { - /** @var ISession */ - private $session; - - /** - * @param ISession $session - */ - public function __construct(ISession $session) { - $this->session = $session; + public function __construct( + private ISession $session, + ) { } - /** - * @param ISession $session - */ - public function setSession(ISession $session) { + public function setSession(ISession $session): void { $this->session = $session; } /** * Returns the current token or throws an exception if none is found. * - * @return string * @throws \Exception */ public function getToken(): string { @@ -70,23 +41,20 @@ class SessionStorage { /** * Set the valid current token to $value. - * - * @param string $value */ - public function setToken(string $value) { + public function setToken(string $value): void { $this->session->set('requesttoken', $value); } /** * Removes the current token. */ - public function removeToken() { + public function removeToken(): void { $this->session->remove('requesttoken'); } + /** * Whether the storage has a storage. - * - * @return bool */ public function hasToken(): bool { return $this->session->exists('requesttoken'); diff --git a/lib/private/Security/Certificate.php b/lib/private/Security/Certificate.php index c89122f9a4b..1551694c21f 100644 --- a/lib/private/Security/Certificate.php +++ b/lib/private/Security/Certificate.php @@ -1,55 +1,36 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security; use OCP\ICertificate; class Certificate implements ICertificate { - protected $name; + protected string $name; - protected $commonName; + protected ?string $commonName; - protected $organization; + protected ?string $organization; - protected $serial; - protected $issueDate; + protected \DateTime $issueDate; - protected $expireDate; + protected \DateTime $expireDate; - protected $issuerName; + protected ?string $issuerName; - protected $issuerOrganization; + protected ?string $issuerOrganization; /** * @param string $data base64 encoded certificate - * @param string $name * @throws \Exception If the certificate could not get parsed */ - public function __construct($data, $name) { + public function __construct(string $data, string $name) { $this->name = $name; $gmt = new \DateTimeZone('GMT'); @@ -61,71 +42,57 @@ class Certificate implements ICertificate { $info = openssl_x509_parse($data); if (!is_array($info)) { + // There is a non-standardized certificate format only used by OpenSSL. Replace all + // separators and try again. + $data = str_replace( + ['-----BEGIN TRUSTED CERTIFICATE-----', '-----END TRUSTED CERTIFICATE-----'], + ['-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'], + $data, + ); + $info = openssl_x509_parse($data); + } + if (!is_array($info)) { throw new \Exception('Certificate could not get parsed.'); } - $this->commonName = isset($info['subject']['CN']) ? $info['subject']['CN'] : null; - $this->organization = isset($info['subject']['O']) ? $info['subject']['O'] : null; + $this->commonName = $info['subject']['CN'] ?? null; + $this->organization = $info['subject']['O'] ?? null; $this->issueDate = new \DateTime('@' . $info['validFrom_time_t'], $gmt); $this->expireDate = new \DateTime('@' . $info['validTo_time_t'], $gmt); - $this->issuerName = isset($info['issuer']['CN']) ? $info['issuer']['CN'] : null; - $this->issuerOrganization = isset($info['issuer']['O']) ? $info['issuer']['O'] : null; + $this->issuerName = $info['issuer']['CN'] ?? null; + $this->issuerOrganization = $info['issuer']['O'] ?? null; } - /** - * @return string - */ - public function getName() { + public function getName(): string { return $this->name; } - /** - * @return string|null - */ - public function getCommonName() { + public function getCommonName(): ?string { return $this->commonName; } - /** - * @return string - */ - public function getOrganization() { + public function getOrganization(): ?string { return $this->organization; } - /** - * @return \DateTime - */ - public function getIssueDate() { + public function getIssueDate(): \DateTime { return $this->issueDate; } - /** - * @return \DateTime - */ - public function getExpireDate() { + public function getExpireDate(): \DateTime { return $this->expireDate; } - /** - * @return bool - */ - public function isExpired() { + public function isExpired(): bool { $now = new \DateTime(); return $this->issueDate > $now or $now > $this->expireDate; } - /** - * @return string|null - */ - public function getIssuerName() { + public function getIssuerName(): ?string { return $this->issuerName; } - /** - * @return string|null - */ - public function getIssuerOrganization() { + public function getIssuerOrganization(): ?string { return $this->issuerOrganization; } } diff --git a/lib/private/Security/CertificateManager.php b/lib/private/Security/CertificateManager.php index ef0c6563320..00babff735f 100644 --- a/lib/private/Security/CertificateManager.php +++ b/lib/private/Security/CertificateManager.php @@ -1,76 +1,32 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security; -use OC\Files\Filesystem; +use OC\Files\View; +use OCP\ICertificate; use OCP\ICertificateManager; use OCP\IConfig; -use OCP\ILogger; use OCP\Security\ISecureRandom; +use Psr\Log\LoggerInterface; /** * Manage trusted certificates for users */ class CertificateManager implements ICertificateManager { - /** - * @var \OC\Files\View - */ - protected $view; - - /** - * @var IConfig - */ - protected $config; - - /** - * @var ILogger - */ - protected $logger; - - /** @var ISecureRandom */ - protected $random; - - /** - * @param \OC\Files\View $view relative to data/ - * @param IConfig $config - * @param ILogger $logger - * @param ISecureRandom $random - */ - public function __construct(\OC\Files\View $view, - IConfig $config, - ILogger $logger, - ISecureRandom $random) { - $this->view = $view; - $this->config = $config; - $this->logger = $logger; - $this->random = $random; + private ?string $bundlePath = null; + + public function __construct( + protected View $view, + protected IConfig $config, + protected LoggerInterface $logger, + protected ISecureRandom $random, + ) { } /** @@ -78,8 +34,8 @@ class CertificateManager implements ICertificateManager { * * @return \OCP\ICertificate[] */ - public function listCertificates() { - if (!$this->config->getSystemValue('installed', false)) { + public function listCertificates(): array { + if (!$this->config->getSystemValueBool('installed', false)) { return []; } @@ -95,8 +51,14 @@ class CertificateManager implements ICertificateManager { while (false !== ($file = readdir($handle))) { if ($file != '.' && $file != '..') { try { - $result[] = new Certificate($this->view->file_get_contents($path . $file), $file); + $content = $this->view->file_get_contents($path . $file); + if ($content !== false) { + $result[] = new Certificate($content, $file); + } else { + $this->logger->error("Failed to read certificate from $path"); + } } catch (\Exception $e) { + $this->logger->error("Failed to read certificate from $path", ['exception' => $e]); } } } @@ -105,7 +67,7 @@ class CertificateManager implements ICertificateManager { } private function hasCertificates(): bool { - if (!$this->config->getSystemValue('installed', false)) { + if (!$this->config->getSystemValueBool('installed', false)) { return false; } @@ -130,7 +92,7 @@ class CertificateManager implements ICertificateManager { /** * create the certificate bundle of all trusted certificated */ - public function createCertificateBundle() { + public function createCertificateBundle(): void { $path = $this->getPathToCertificates(); $certs = $this->listCertificates(); @@ -141,7 +103,8 @@ class CertificateManager implements ICertificateManager { $defaultCertificates = file_get_contents(\OC::$SERVERROOT . '/resources/config/ca-bundle.crt'); if (strlen($defaultCertificates) < 1024) { // sanity check to verify that we have some content for our bundle // log as exception so we have a stacktrace - $this->logger->logException(new \Exception('Shipped ca-bundle is empty, refusing to create certificate bundle')); + $e = new \Exception('Shipped ca-bundle is empty, refusing to create certificate bundle'); + $this->logger->error($e->getMessage(), ['exception' => $e]); return; } @@ -149,6 +112,10 @@ class CertificateManager implements ICertificateManager { $tmpPath = $certPath . '.tmp' . $this->random->generate(10, ISecureRandom::CHAR_DIGITS); $fhCerts = $this->view->fopen($tmpPath, 'w'); + if (!is_resource($fhCerts)) { + throw new \RuntimeException('Unable to open file handler to create certificate bundle "' . $tmpPath . '".'); + } + // Write user certificates foreach ($certs as $cert) { $file = $path . '/uploads/' . $cert->getName(); @@ -179,23 +146,22 @@ class CertificateManager implements ICertificateManager { * * @param string $certificate the certificate data * @param string $name the filename for the certificate - * @return \OCP\ICertificate * @throws \Exception If the certificate could not get added */ - public function addCertificate($certificate, $name) { - if (!Filesystem::isValidPath($name) or Filesystem::isFileBlacklisted($name)) { - throw new \Exception('Filename is not valid'); - } + public function addCertificate(string $certificate, string $name): ICertificate { + $path = $this->getPathToCertificates() . 'uploads/' . $name; + $directory = dirname($path); - $dir = $this->getPathToCertificates() . 'uploads/'; - if (!$this->view->file_exists($dir)) { - $this->view->mkdir($dir); + $this->view->verifyPath($directory, basename($path)); + $this->bundlePath = null; + + if (!$this->view->file_exists($directory)) { + $this->view->mkdir($directory); } try { - $file = $dir . $name; $certificateObject = new Certificate($certificate, $name); - $this->view->file_put_contents($file, $certificate); + $this->view->file_put_contents($path, $certificate); $this->createCertificateBundle(); return $certificateObject; } catch (\Exception $e) { @@ -205,17 +171,19 @@ class CertificateManager implements ICertificateManager { /** * Remove the certificate and re-generate the certificate bundle - * - * @param string $name - * @return bool */ - public function removeCertificate($name) { - if (!Filesystem::isValidPath($name)) { + public function removeCertificate(string $name): bool { + $path = $this->getPathToCertificates() . 'uploads/' . $name; + + try { + $this->view->verifyPath(dirname($path), basename($path)); + } catch (\Exception) { return false; } - $path = $this->getPathToCertificates() . 'uploads/'; - if ($this->view->file_exists($path . $name)) { - $this->view->unlink($path . $name); + + $this->bundlePath = null; + if ($this->view->file_exists($path)) { + $this->view->unlink($path); $this->createCertificateBundle(); } return true; @@ -223,43 +191,48 @@ class CertificateManager implements ICertificateManager { /** * Get the path to the certificate bundle - * - * @return string */ - public function getCertificateBundle() { + public function getCertificateBundle(): string { return $this->getPathToCertificates() . 'rootcerts.crt'; } /** * Get the full local path to the certificate bundle - * - * @return string + * @throws \Exception when getting bundle path fails */ - public function getAbsoluteBundlePath() { - if (!$this->hasCertificates()) { + public function getAbsoluteBundlePath(): string { + try { + if ($this->bundlePath === null) { + if (!$this->hasCertificates()) { + $this->bundlePath = \OC::$SERVERROOT . '/resources/config/ca-bundle.crt'; + } else { + if ($this->needsRebundling()) { + $this->createCertificateBundle(); + } + + $certificateBundle = $this->getCertificateBundle(); + $this->bundlePath = $this->view->getLocalFile($certificateBundle) ?: null; + + if ($this->bundlePath === null) { + throw new \RuntimeException('Unable to get certificate bundle "' . $certificateBundle . '".'); + } + } + } + return $this->bundlePath; + } catch (\Exception $e) { + $this->logger->error('Failed to get absolute bundle path. Fallback to default ca-bundle.crt', ['exception' => $e]); return \OC::$SERVERROOT . '/resources/config/ca-bundle.crt'; } - - if ($this->needsRebundling()) { - $this->createCertificateBundle(); - } - - return $this->view->getLocalFile($this->getCertificateBundle()); } - /** - * @return string - */ - private function getPathToCertificates() { + private function getPathToCertificates(): string { return '/files_external/'; } /** * Check if we need to re-bundle the certificates because one of the sources has updated - * - * @return bool */ - private function needsRebundling() { + private function needsRebundling(): bool { $targetBundle = $this->getCertificateBundle(); if (!$this->view->file_exists($targetBundle)) { return true; @@ -271,10 +244,8 @@ class CertificateManager implements ICertificateManager { /** * get mtime of ca-bundle shipped by Nextcloud - * - * @return int */ - protected function getFilemtimeOfCaBundle() { + protected function getFilemtimeOfCaBundle(): int { return filemtime(\OC::$SERVERROOT . '/resources/config/ca-bundle.crt'); } } diff --git a/lib/private/Security/CredentialsManager.php b/lib/private/Security/CredentialsManager.php index 7ba8a0020ff..254984261d2 100644 --- a/lib/private/Security/CredentialsManager.php +++ b/lib/private/Security/CredentialsManager.php @@ -1,28 +1,11 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security; use OCP\IDBConnection; @@ -37,33 +20,23 @@ use OCP\Security\ICrypto; class CredentialsManager implements ICredentialsManager { public const DB_TABLE = 'storages_credentials'; - /** @var ICrypto */ - protected $crypto; - - /** @var IDBConnection */ - protected $dbConnection; - - /** - * @param ICrypto $crypto - * @param IDBConnection $dbConnection - */ - public function __construct(ICrypto $crypto, IDBConnection $dbConnection) { - $this->crypto = $crypto; - $this->dbConnection = $dbConnection; + public function __construct( + protected ICrypto $crypto, + protected IDBConnection $dbConnection, + ) { } /** * Store a set of credentials * * @param string $userId empty string for system-wide credentials - * @param string $identifier * @param mixed $credentials */ - public function store($userId, $identifier, $credentials) { + public function store(string $userId, string $identifier, $credentials): void { $value = $this->crypto->encrypt(json_encode($credentials)); $this->dbConnection->setValues(self::DB_TABLE, [ - 'user' => (string)$userId, + 'user' => $userId, 'identifier' => $identifier, ], [ 'credentials' => $value, @@ -74,10 +47,8 @@ class CredentialsManager implements ICredentialsManager { * Retrieve a set of credentials * * @param string $userId empty string for system-wide credentials - * @param string $identifier - * @return mixed */ - public function retrieve($userId, $identifier) { + public function retrieve(string $userId, string $identifier): mixed { $qb = $this->dbConnection->getQueryBuilder(); $qb->select('credentials') ->from(self::DB_TABLE) @@ -86,10 +57,10 @@ class CredentialsManager implements ICredentialsManager { if ($userId === '') { $qb->andWhere($qb->expr()->emptyString('user')); } else { - $qb->andWhere($qb->expr()->eq('user', $qb->createNamedParameter((string)$userId))); + $qb->andWhere($qb->expr()->eq('user', $qb->createNamedParameter($userId))); } - $qResult = $qb->execute(); + $qResult = $qb->executeQuery(); $result = $qResult->fetch(); $qResult->closeCursor(); @@ -105,10 +76,9 @@ class CredentialsManager implements ICredentialsManager { * Delete a set of credentials * * @param string $userId empty string for system-wide credentials - * @param string $identifier * @return int rows removed */ - public function delete($userId, $identifier) { + public function delete(string $userId, string $identifier): int { $qb = $this->dbConnection->getQueryBuilder(); $qb->delete(self::DB_TABLE) ->where($qb->expr()->eq('identifier', $qb->createNamedParameter($identifier))); @@ -116,23 +86,22 @@ class CredentialsManager implements ICredentialsManager { if ($userId === '') { $qb->andWhere($qb->expr()->emptyString('user')); } else { - $qb->andWhere($qb->expr()->eq('user', $qb->createNamedParameter((string)$userId))); + $qb->andWhere($qb->expr()->eq('user', $qb->createNamedParameter($userId))); } - return $qb->execute(); + return $qb->executeStatement(); } /** * Erase all credentials stored for a user * - * @param string $userId * @return int rows removed */ - public function erase($userId) { + public function erase(string $userId): int { $qb = $this->dbConnection->getQueryBuilder(); $qb->delete(self::DB_TABLE) ->where($qb->expr()->eq('user', $qb->createNamedParameter($userId))) ; - return $qb->execute(); + return $qb->executeStatement(); } } diff --git a/lib/private/Security/Crypto.php b/lib/private/Security/Crypto.php index 7b1e1a49b19..39ce5e89aeb 100644 --- a/lib/private/Security/Crypto.php +++ b/lib/private/Security/Crypto.php @@ -1,38 +1,16 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Andreas Fischer <bantu@owncloud.com> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author lynn-stephenson <lynn.stephenson@protonmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security; +use Exception; use OCP\IConfig; use OCP\Security\ICrypto; -use OCP\Security\ISecureRandom; use phpseclib\Crypt\AES; use phpseclib\Crypt\Hash; @@ -47,20 +25,13 @@ use phpseclib\Crypt\Hash; * @package OC\Security */ class Crypto implements ICrypto { - /** @var AES $cipher */ - private $cipher; - /** @var int */ - private $ivLength = 16; - /** @var IConfig */ - private $config; + private AES $cipher; + private int $ivLength = 16; - /** - * @param IConfig $config - * @param ISecureRandom $random - */ - public function __construct(IConfig $config) { + public function __construct( + private IConfig $config, + ) { $this->cipher = new AES(); - $this->config = $config; } /** @@ -70,7 +41,7 @@ class Crypto implements ICrypto { */ public function calculateHMAC(string $message, string $password = ''): string { if ($password === '') { - $password = $this->config->getSystemValue('secret'); + $password = $this->config->getSystemValueString('secret'); } // Append an "a" behind the password and hash it to prevent reusing the same password as for encryption @@ -83,13 +54,15 @@ class Crypto implements ICrypto { /** * Encrypts a value and adds an HMAC (Encrypt-Then-MAC) - * @param string $plaintext + * * @param string $password Password to encrypt, if not specified the secret from config.php will be taken * @return string Authenticated ciphertext + * @throws Exception if it was not possible to gather sufficient entropy + * @throws Exception if encrypting the data failed */ public function encrypt(string $plaintext, string $password = ''): string { if ($password === '') { - $password = $this->config->getSystemValue('secret'); + $password = $this->config->getSystemValueString('secret'); } $keyMaterial = hash_hkdf('sha512', $password); $this->cipher->setPassword(substr($keyMaterial, 0, 32)); @@ -97,44 +70,80 @@ class Crypto implements ICrypto { $iv = \random_bytes($this->ivLength); $this->cipher->setIV($iv); - $ciphertext = bin2hex($this->cipher->encrypt($plaintext)); + /** @var string|false $encrypted */ + $encrypted = $this->cipher->encrypt($plaintext); + if ($encrypted === false) { + throw new Exception('Encrypting failed.'); + } + + $ciphertext = bin2hex($encrypted); $iv = bin2hex($iv); - $hmac = bin2hex($this->calculateHMAC($ciphertext.$iv, substr($keyMaterial, 32))); + $hmac = bin2hex($this->calculateHMAC($ciphertext . $iv, substr($keyMaterial, 32))); - return $ciphertext.'|'.$iv.'|'.$hmac.'|3'; + return $ciphertext . '|' . $iv . '|' . $hmac . '|3'; } /** * Decrypts a value and verifies the HMAC (Encrypt-Then-Mac) - * @param string $authenticatedCiphertext * @param string $password Password to encrypt, if not specified the secret from config.php will be taken - * @return string plaintext - * @throws \Exception If the HMAC does not match - * @throws \Exception If the decryption failed + * @throws Exception If the HMAC does not match + * @throws Exception If the decryption failed */ public function decrypt(string $authenticatedCiphertext, string $password = ''): string { - if ($password === '') { - $password = $this->config->getSystemValue('secret'); + $secret = $this->config->getSystemValue('secret'); + try { + if ($password === '') { + return $this->decryptWithoutSecret($authenticatedCiphertext, $secret); + } + return $this->decryptWithoutSecret($authenticatedCiphertext, $password); + } catch (Exception $e) { + if ($password === '') { + // Retry with empty secret as a fallback for instances where the secret might not have been set by accident + return $this->decryptWithoutSecret($authenticatedCiphertext, ''); + } + throw $e; } + } + + private function decryptWithoutSecret(string $authenticatedCiphertext, string $password = ''): string { $hmacKey = $encryptionKey = $password; $parts = explode('|', $authenticatedCiphertext); $partCount = \count($parts); if ($partCount < 3 || $partCount > 4) { - throw new \Exception('Authenticated ciphertext could not be decoded.'); + throw new Exception('Authenticated ciphertext could not be decoded.'); + } + + /* + * Rearrange arguments for legacy ownCloud migrations + * + * The original scheme consistent of three parts. Nextcloud added a + * fourth at the end as "2" or later "3", ownCloud added "v2" at the + * beginning. + */ + $originalParts = $parts; + $isOwnCloudV2Migration = $partCount === 4 && $originalParts[0] === 'v2'; + if ($isOwnCloudV2Migration) { + $parts = [ + $parts[1], + $parts[2], + $parts[3], + '2' + ]; } - $ciphertext = hex2bin($parts[0]); + // Convert hex-encoded values to binary + $ciphertext = $this->hex2bin($parts[0]); $iv = $parts[1]; - $hmac = hex2bin($parts[2]); + $hmac = $this->hex2bin($parts[2]); if ($partCount === 4) { $version = $parts[3]; if ($version >= '2') { - $iv = hex2bin($iv); + $iv = $this->hex2bin($iv); } - if ($version === '3') { + if ($version === '3' || $isOwnCloudV2Migration) { $keyMaterial = hash_hkdf('sha512', $password); $encryptionKey = substr($keyMaterial, 0, 32); $hmacKey = substr($keyMaterial, 32); @@ -143,13 +152,36 @@ class Crypto implements ICrypto { $this->cipher->setPassword($encryptionKey); $this->cipher->setIV($iv); - if (!hash_equals($this->calculateHMAC($parts[0] . $parts[1], $hmacKey), $hmac)) { - throw new \Exception('HMAC does not match.'); + if ($isOwnCloudV2Migration) { + // ownCloud uses the binary IV for HMAC calculation + if (!hash_equals($this->calculateHMAC($parts[0] . $iv, $hmacKey), $hmac)) { + throw new Exception('HMAC does not match.'); + } + } else { + if (!hash_equals($this->calculateHMAC($parts[0] . $parts[1], $hmacKey), $hmac)) { + throw new Exception('HMAC does not match.'); + } } $result = $this->cipher->decrypt($ciphertext); if ($result === false) { - throw new \Exception('Decryption failed'); + throw new Exception('Decryption failed'); + } + + return $result; + } + + private function hex2bin(string $hex): string { + if (!ctype_xdigit($hex)) { + throw new \RuntimeException('String contains non hex chars: ' . $hex); + } + if (strlen($hex) % 2 !== 0) { + throw new \RuntimeException('Hex string is not of even length: ' . $hex); + } + $result = hex2bin($hex); + + if ($result === false) { + throw new \RuntimeException('Hex to bin conversion failed: ' . $hex); } return $result; diff --git a/lib/private/Security/FeaturePolicy/FeaturePolicy.php b/lib/private/Security/FeaturePolicy/FeaturePolicy.php index 93556708789..9b513b80813 100644 --- a/lib/private/Security/FeaturePolicy/FeaturePolicy.php +++ b/lib/private/Security/FeaturePolicy/FeaturePolicy.php @@ -3,27 +3,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Security\FeaturePolicy; class FeaturePolicy extends \OCP\AppFramework\Http\FeaturePolicy { diff --git a/lib/private/Security/FeaturePolicy/FeaturePolicyManager.php b/lib/private/Security/FeaturePolicy/FeaturePolicyManager.php index b2959c310c8..e50aaac0faf 100644 --- a/lib/private/Security/FeaturePolicy/FeaturePolicyManager.php +++ b/lib/private/Security/FeaturePolicy/FeaturePolicyManager.php @@ -3,28 +3,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Security\FeaturePolicy; use OCP\AppFramework\Http\EmptyFeaturePolicy; @@ -33,13 +14,11 @@ use OCP\Security\FeaturePolicy\AddFeaturePolicyEvent; class FeaturePolicyManager { /** @var EmptyFeaturePolicy[] */ - private $policies = []; - - /** @var IEventDispatcher */ - private $dispatcher; + private array $policies = []; - public function __construct(IEventDispatcher $dispatcher) { - $this->dispatcher = $dispatcher; + public function __construct( + private IEventDispatcher $dispatcher, + ) { } public function addDefaultPolicy(EmptyFeaturePolicy $policy): void { @@ -61,8 +40,10 @@ class FeaturePolicyManager { * Merges the first given policy with the second one * */ - public function mergePolicies(FeaturePolicy $defaultPolicy, - EmptyFeaturePolicy $originalPolicy): FeaturePolicy { + public function mergePolicies( + FeaturePolicy $defaultPolicy, + EmptyFeaturePolicy $originalPolicy, + ): FeaturePolicy { foreach ((object)(array)$originalPolicy as $name => $value) { $setter = 'set' . ucfirst($name); if (\is_array($value)) { diff --git a/lib/private/Security/Hasher.php b/lib/private/Security/Hasher.php index 4b068ce0110..722fdab902f 100644 --- a/lib/private/Security/Hasher.php +++ b/lib/private/Security/Hasher.php @@ -1,33 +1,11 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author MichaIng <micha@dietpi.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security; use OCP\IConfig; @@ -44,28 +22,23 @@ use OCP\Security\IHasher; * * Usage: * // Hashing a message - * $hash = \OC::$server->getHasher()->hash('MessageToHash'); + * $hash = \OC::$server->get(\OCP\Security\IHasher::class)->hash('MessageToHash'); * // Verifying a message - $newHash will contain the newly calculated hash * $newHash = null; - * var_dump(\OC::$server->getHasher()->verify('a', '86f7e437faa5a7fce15d1ddcb9eaeaea377667b8', $newHash)); + * var_dump(\OC::$server->get(\OCP\Security\IHasher::class)->verify('a', '86f7e437faa5a7fce15d1ddcb9eaeaea377667b8', $newHash)); * var_dump($newHash); * * @package OC\Security */ class Hasher implements IHasher { - /** @var IConfig */ - private $config; - /** @var array Options passed to password_hash and password_needs_rehash */ - private $options = []; - /** @var string Salt used for legacy passwords */ - private $legacySalt = null; - - /** - * @param IConfig $config - */ - public function __construct(IConfig $config) { - $this->config = $config; - + /** Options passed to password_hash and password_needs_rehash */ + private array $options = []; + /** Salt used for legacy passwords */ + private ?string $legacySalt = null; + + public function __construct( + private IConfig $config, + ) { if (\defined('PASSWORD_ARGON2ID') || \defined('PASSWORD_ARGON2I')) { // password_hash fails, when the minimum values are undershot. // In this case, apply minimum. @@ -106,9 +79,9 @@ class Hasher implements IHasher { /** * Get the version and hash from a prefixedHash * @param string $prefixedHash - * @return null|array Null if the hash is not prefixed, otherwise array('version' => 1, 'hash' => 'foo') + * @return null|array{version: int, hash: string} Null if the hash is not prefixed, otherwise array('version' => 1, 'hash' => 'foo') */ - protected function splitHash(string $prefixedHash) { + protected function splitHash(string $prefixedHash): ?array { $explodedString = explode('|', $prefixedHash, 2); if (\count($explodedString) === 2) { if ((int)$explodedString[0] > 0) { @@ -133,8 +106,17 @@ class Hasher implements IHasher { // Verify whether it matches a legacy PHPass or SHA1 string $hashLength = \strlen($hash); - if (($hashLength === 60 && password_verify($message.$this->legacySalt, $hash)) || - ($hashLength === 40 && hash_equals($hash, sha1($message)))) { + if (($hashLength === 60 && password_verify($message . $this->legacySalt, $hash)) + || ($hashLength === 40 && hash_equals($hash, sha1($message)))) { + $newHash = $this->hash($message); + return true; + } + + // Verify whether it matches a legacy PHPass or SHA1 string + // Retry with empty passwordsalt for cases where it was not set + $hashLength = \strlen($hash); + if (($hashLength === 60 && password_verify($message, $hash)) + || ($hashLength === 40 && hash_equals($hash, sha1($message)))) { $newHash = $this->hash($message); return true; } @@ -191,7 +173,7 @@ class Hasher implements IHasher { return password_needs_rehash($hash, $algorithm, $this->options); } - private function getPrefferedAlgorithm() { + private function getPrefferedAlgorithm(): string { $default = PASSWORD_BCRYPT; if (\defined('PASSWORD_ARGON2I')) { $default = PASSWORD_ARGON2I; @@ -202,10 +184,24 @@ class Hasher implements IHasher { } // Check if we should use PASSWORD_DEFAULT - if ($this->config->getSystemValue('hashing_default_password', false) === true) { + if ($this->config->getSystemValueBool('hashing_default_password', false)) { $default = PASSWORD_DEFAULT; } return $default; } + + public function validate(string $prefixedHash): bool { + $splitHash = $this->splitHash($prefixedHash); + if (empty($splitHash)) { + return false; + } + $validVersions = [3, 2, 1]; + $version = $splitHash['version']; + if (!in_array($version, $validVersions, true)) { + return false; + } + $algoName = password_get_info($splitHash['hash'])['algoName']; + return $algoName !== 'unknown'; + } } diff --git a/lib/private/Security/IdentityProof/Key.php b/lib/private/Security/IdentityProof/Key.php index 342d44b4a3b..0bfcd6bf9ed 100644 --- a/lib/private/Security/IdentityProof/Key.php +++ b/lib/private/Security/IdentityProof/Key.php @@ -3,43 +3,16 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 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: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Security\IdentityProof; class Key { - /** @var string */ - private $publicKey; - /** @var string */ - private $privateKey; - - /** - * @param string $publicKey - * @param string $privateKey - */ - public function __construct(string $publicKey, string $privateKey) { - $this->publicKey = $publicKey; - $this->privateKey = $privateKey; + public function __construct( + private string $publicKey, + private string $privateKey, + ) { } public function getPrivate(): string { diff --git a/lib/private/Security/IdentityProof/Manager.php b/lib/private/Security/IdentityProof/Manager.php index 2fa09da3189..c16b8314beb 100644 --- a/lib/private/Security/IdentityProof/Manager.php +++ b/lib/private/Security/IdentityProof/Manager.php @@ -3,77 +3,55 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch> - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @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: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Security\IdentityProof; use OC\Files\AppData\Factory; use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\ICache; +use OCP\ICacheFactory; use OCP\IConfig; -use OCP\ILogger; use OCP\IUser; use OCP\Security\ICrypto; +use Psr\Log\LoggerInterface; class Manager { - /** @var IAppData */ - private $appData; - /** @var ICrypto */ - private $crypto; - /** @var IConfig */ - private $config; - /** @var ILogger */ - private $logger; - - public function __construct(Factory $appDataFactory, - ICrypto $crypto, - IConfig $config, - ILogger $logger + private IAppData $appData; + + protected ICache $cache; + + public function __construct( + Factory $appDataFactory, + private ICrypto $crypto, + private IConfig $config, + private LoggerInterface $logger, + private ICacheFactory $cacheFactory, ) { $this->appData = $appDataFactory->get('identityproof'); - $this->crypto = $crypto; - $this->config = $config; - $this->logger = $logger; + $this->cache = $this->cacheFactory->createDistributed('identityproof::'); } /** * Calls the openssl functions to generate a public and private key. * In a separate function for unit testing purposes. * + * @param array $options config options to generate key {@see openssl_csr_new} + * * @return array [$publicKey, $privateKey] * @throws \RuntimeException */ - protected function generateKeyPair(): array { + protected function generateKeyPair(array $options = []): array { $config = [ - 'digest_alg' => 'sha512', - 'private_key_bits' => 2048, + 'digest_alg' => $options['algorithm'] ?? 'sha512', + 'private_key_bits' => $options['bits'] ?? 2048, + 'private_key_type' => $options['type'] ?? OPENSSL_KEYTYPE_RSA, ]; // Generate new key $res = openssl_pkey_new($config); - if ($res === false) { $this->logOpensslError(); throw new \RuntimeException('OpenSSL reported a problem'); @@ -96,16 +74,17 @@ class Manager { * Note: If a key already exists it will be overwritten * * @param string $id key id - * @return Key + * @param array $options config options to generate key {@see openssl_csr_new} + * * @throws \RuntimeException */ - protected function generateKey(string $id): Key { - list($publicKey, $privateKey) = $this->generateKeyPair(); + protected function generateKey(string $id, array $options = []): Key { + [$publicKey, $privateKey] = $this->generateKeyPair($options); // Write the private and public key to the disk try { $this->appData->newFolder($id); - } catch (\Exception $e) { + } catch (\Exception) { } $folder = $this->appData->getFolder($id); $folder->newFile('private') @@ -119,18 +98,28 @@ class Manager { /** * Get key for a specific id * - * @param string $id - * @return Key * @throws \RuntimeException */ protected function retrieveKey(string $id): Key { try { + $cachedPublicKey = $this->cache->get($id . '-public'); + $cachedPrivateKey = $this->cache->get($id . '-private'); + + if ($cachedPublicKey !== null && $cachedPrivateKey !== null) { + $decryptedPrivateKey = $this->crypto->decrypt($cachedPrivateKey); + + return new Key($cachedPublicKey, $decryptedPrivateKey); + } + $folder = $this->appData->getFolder($id); - $privateKey = $this->crypto->decrypt( - $folder->getFile('private')->getContent() - ); + $privateKey = $folder->getFile('private')->getContent(); $publicKey = $folder->getFile('public')->getContent(); - return new Key($publicKey, $privateKey); + + $this->cache->set($id . '-public', $publicKey); + $this->cache->set($id . '-private', $privateKey); + + $decryptedPrivateKey = $this->crypto->decrypt($privateKey); + return new Key($publicKey, $decryptedPrivateKey); } catch (\Exception $e) { return $this->generateKey($id); } @@ -139,8 +128,6 @@ class Manager { /** * Get public and private key for $user * - * @param IUser $user - * @return Key * @throws \RuntimeException */ public function getKey(IUser $user): Key { @@ -151,7 +138,6 @@ class Manager { /** * Get instance wide public and private key * - * @return Key * @throws \RuntimeException */ public function getSystemKey(): Key { @@ -162,6 +148,38 @@ class Manager { return $this->retrieveKey('system-' . $instanceId); } + public function hasAppKey(string $app, string $name): bool { + $id = $this->generateAppKeyId($app, $name); + try { + $folder = $this->appData->getFolder($id); + return ($folder->fileExists('public') && $folder->fileExists('private')); + } catch (NotFoundException) { + return false; + } + } + + public function getAppKey(string $app, string $name): Key { + return $this->retrieveKey($this->generateAppKeyId($app, $name)); + } + + public function generateAppKey(string $app, string $name, array $options = []): Key { + return $this->generateKey($this->generateAppKeyId($app, $name), $options); + } + + public function deleteAppKey(string $app, string $name): bool { + try { + $folder = $this->appData->getFolder($this->generateAppKeyId($app, $name)); + $folder->delete(); + return true; + } catch (NotFoundException) { + return false; + } + } + + private function generateAppKeyId(string $app, string $name): string { + return 'app-' . $app . '-' . $name; + } + private function logOpensslError(): void { $errors = []; while ($error = openssl_error_string()) { diff --git a/lib/private/Security/IdentityProof/Signer.php b/lib/private/Security/IdentityProof/Signer.php index 26293a5ec4a..6083cbb5c9b 100644 --- a/lib/private/Security/IdentityProof/Signer.php +++ b/lib/private/Security/IdentityProof/Signer.php @@ -3,29 +3,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @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: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Security\IdentityProof; use OCP\AppFramework\Utility\ITimeFactory; @@ -33,32 +13,16 @@ use OCP\IUser; use OCP\IUserManager; class Signer { - /** @var Manager */ - private $keyManager; - /** @var ITimeFactory */ - private $timeFactory; - /** @var IUserManager */ - private $userManager; - - /** - * @param Manager $keyManager - * @param ITimeFactory $timeFactory - * @param IUserManager $userManager - */ - public function __construct(Manager $keyManager, - ITimeFactory $timeFactory, - IUserManager $userManager) { - $this->keyManager = $keyManager; - $this->timeFactory = $timeFactory; - $this->userManager = $userManager; + public function __construct( + private Manager $keyManager, + private ITimeFactory $timeFactory, + private IUserManager $userManager, + ) { } /** * Returns a signed blob for $data * - * @param string $type - * @param array $data - * @param IUser $user * @return array ['message', 'signature'] */ public function sign(string $type, array $data, IUser $user): array { @@ -80,13 +44,10 @@ class Signer { /** * Whether the data is signed properly * - * @param array $data - * @return bool */ public function verify(array $data): bool { - if (isset($data['message']) + if (isset($data['message']['signer']) && isset($data['signature']) - && isset($data['message']['signer']) ) { $location = strrpos($data['message']['signer'], '@'); $userId = substr($data['message']['signer'], 0, $location); @@ -94,12 +55,12 @@ class Signer { $user = $this->userManager->get($userId); if ($user !== null) { $key = $this->keyManager->getKey($user); - return (bool)openssl_verify( + return openssl_verify( json_encode($data['message']), base64_decode($data['signature']), $key->getPublic(), OPENSSL_ALGO_SHA512 - ); + ) === 1; } } diff --git a/lib/private/Security/Ip/Address.php b/lib/private/Security/Ip/Address.php new file mode 100644 index 00000000000..0e94ec2d9ea --- /dev/null +++ b/lib/private/Security/Ip/Address.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Security\Ip; + +use InvalidArgumentException; +use IPLib\Address\AddressInterface; +use IPLib\Factory; +use IPLib\ParseStringFlag; +use OCP\Security\Ip\IAddress; +use OCP\Security\Ip\IRange; + +/** + * @since 30.0.0 + */ +class Address implements IAddress { + private readonly AddressInterface $ip; + + public function __construct(string $ip) { + $ip = Factory::parseAddressString($ip, ParseStringFlag::MAY_INCLUDE_ZONEID); + if ($ip === null) { + throw new InvalidArgumentException('Given IP address can’t be parsed'); + } + $this->ip = $ip; + } + + public static function isValid(string $ip): bool { + return Factory::parseAddressString($ip, ParseStringFlag::MAY_INCLUDE_ZONEID) !== null; + } + + public function matches(IRange ... $ranges): bool { + foreach ($ranges as $range) { + if ($range->contains($this)) { + return true; + } + } + + return false; + } + + public function __toString(): string { + return $this->ip->toString(); + } +} diff --git a/lib/private/Security/Ip/BruteforceAllowList.php b/lib/private/Security/Ip/BruteforceAllowList.php new file mode 100644 index 00000000000..fb837690a7b --- /dev/null +++ b/lib/private/Security/Ip/BruteforceAllowList.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\Ip; + +use OCP\IAppConfig; +use OCP\Security\Ip\IFactory; + +class BruteforceAllowList { + /** @var array<string, bool> */ + protected array $ipIsAllowListed = []; + + public function __construct( + private readonly IAppConfig $appConfig, + private readonly IFactory $factory, + ) { + } + + /** + * Check if the IP is allowed to bypass bruteforce protection + */ + public function isBypassListed(string $ip): bool { + if (isset($this->ipIsAllowListed[$ip])) { + return $this->ipIsAllowListed[$ip]; + } + + try { + $address = $this->factory->addressFromString($ip); + } catch (\InvalidArgumentException) { + $this->ipIsAllowListed[$ip] = false; + return false; + } + + foreach ($this->appConfig->searchKeys('bruteForce', 'whitelist_') as $key) { + $rangeString = $this->appConfig->getValueString('bruteForce', $key); + try { + $range = $this->factory->rangeFromString($rangeString); + } catch (\InvalidArgumentException) { + continue; + } + + $allowed = $range->contains($address); + if ($allowed) { + $this->ipIsAllowListed[$ip] = true; + return true; + } + } + + $this->ipIsAllowListed[$ip] = false; + return false; + } +} diff --git a/lib/private/Security/Ip/Factory.php b/lib/private/Security/Ip/Factory.php new file mode 100644 index 00000000000..1eedcf27a09 --- /dev/null +++ b/lib/private/Security/Ip/Factory.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Security\Ip; + +use OCP\Security\Ip\IAddress; +use OCP\Security\Ip\IFactory; +use OCP\Security\Ip\IRange; + +class Factory implements IFactory { + public function rangeFromString(string $range): IRange { + return new Range($range); + } + + public function addressFromString(string $ip): IAddress { + return new Address($ip); + } +} diff --git a/lib/private/Security/Ip/Range.php b/lib/private/Security/Ip/Range.php new file mode 100644 index 00000000000..e32b7a5abc0 --- /dev/null +++ b/lib/private/Security/Ip/Range.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Security\Ip; + +use InvalidArgumentException; +use IPLib\Factory; +use IPLib\ParseStringFlag; +use IPLib\Range\RangeInterface; +use OCP\Security\Ip\IAddress; +use OCP\Security\Ip\IRange; + +class Range implements IRange { + private readonly RangeInterface $range; + + public function __construct(string $range) { + $range = Factory::parseRangeString($range); + if ($range === null) { + throw new InvalidArgumentException('Given range can’t be parsed'); + } + $this->range = $range; + } + + public static function isValid(string $range): bool { + return Factory::parseRangeString($range) !== null; + } + + public function contains(IAddress $address): bool { + return $this->range->contains(Factory::parseAddressString((string)$address, ParseStringFlag::MAY_INCLUDE_ZONEID)); + } + + public function __toString(): string { + return $this->range->toString(); + } +} diff --git a/lib/private/Security/Ip/RemoteAddress.php b/lib/private/Security/Ip/RemoteAddress.php new file mode 100644 index 00000000000..4eef8813898 --- /dev/null +++ b/lib/private/Security/Ip/RemoteAddress.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Security\Ip; + +use OCP\IConfig; +use OCP\IRequest; +use OCP\Security\Ip\IAddress; +use OCP\Security\Ip\IRange; +use OCP\Security\Ip\IRemoteAddress; + +class RemoteAddress implements IRemoteAddress, IAddress { + public const SETTING_NAME = 'allowed_admin_ranges'; + + private readonly ?IAddress $ip; + + public function __construct( + private IConfig $config, + IRequest $request, + ) { + $remoteAddress = $request->getRemoteAddress(); + $this->ip = $remoteAddress === '' + ? null + : new Address($remoteAddress); + } + + public static function isValid(string $ip): bool { + return Address::isValid($ip); + } + + public function matches(IRange ... $ranges): bool { + return $this->ip === null + ? true + : $this->ip->matches(... $ranges); + } + + public function allowsAdminActions(): bool { + if ($this->ip === null) { + return true; + } + + $allowedAdminRanges = $this->config->getSystemValue(self::SETTING_NAME, false); + + // Don't apply restrictions on empty or invalid configuration + if ( + $allowedAdminRanges === false + || !is_array($allowedAdminRanges) + || empty($allowedAdminRanges) + ) { + return true; + } + + foreach ($allowedAdminRanges as $allowedAdminRange) { + if ((new Range($allowedAdminRange))->contains($this->ip)) { + return true; + } + } + + return false; + } + + public function __toString(): string { + return (string)$this->ip; + } +} diff --git a/lib/private/Security/Normalizer/IpAddress.php b/lib/private/Security/Normalizer/IpAddress.php index cbfc212e1ce..4d33a7bd632 100644 --- a/lib/private/Security/Normalizer/IpAddress.php +++ b/lib/private/Security/Normalizer/IpAddress.php @@ -3,33 +3,13 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Konrad Bucheli <kb@open.ch> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @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\Normalizer; +use OCP\IConfig; + /** * Class IpAddress is used for normalizing IPv4 and IPv6 addresses in security * relevant contexts in Nextcloud. @@ -37,43 +17,19 @@ namespace OC\Security\Normalizer; * @package OC\Security\Normalizer */ class IpAddress { - /** @var string */ - private $ip; - /** - * @param string $ip IP to normalized + * @param string $ip IP to normalize */ - public function __construct(string $ip) { - $this->ip = $ip; + public function __construct( + private string $ip, + ) { } /** - * Return the given subnet for an IPv4 address and mask bits - * - * @param string $ip - * @param int $maskBits - * @return string - */ - private function getIPv4Subnet(string $ip, int $maskBits = 32): string { - $binary = \inet_pton($ip); - for ($i = 32; $i > $maskBits; $i -= 8) { - $j = \intdiv($i, 8) - 1; - $k = \min(8, $i - $maskBits); - $mask = (0xff - ((2 ** $k) - 1)); - $int = \unpack('C', $binary[$j]); - $binary[$j] = \pack('C', $int[1] & $mask); - } - return \inet_ntop($binary).'/'.$maskBits; - } - - /** - * Return the given subnet for an IPv6 address and mask bits - * - * @param string $ip - * @param int $maskBits - * @return string + * Return the given subnet for an IPv6 address + * Rely on security.ipv6_normalized_subnet_size, defaults to 56 */ - private function getIPv6Subnet(string $ip, int $maskBits = 48): string { + private function getIPv6Subnet(string $ip): string { if ($ip[0] === '[' && $ip[-1] === ']') { // If IP is with brackets, for example [::1] $ip = substr($ip, 1, strlen($ip) - 2); } @@ -81,39 +37,67 @@ class IpAddress { if ($pos !== false) { $ip = substr($ip, 0, $pos - 1); } - $binary = \inet_pton($ip); - for ($i = 128; $i > $maskBits; $i -= 8) { - $j = \intdiv($i, 8) - 1; - $k = \min(8, $i - $maskBits); - $mask = (0xff - ((2 ** $k) - 1)); - $int = \unpack('C', $binary[$j]); - $binary[$j] = \pack('C', $int[1] & $mask); + + $config = \OCP\Server::get(IConfig::class); + $maskSize = min(64, $config->getSystemValueInt('security.ipv6_normalized_subnet_size', 56)); + $maskSize = max(32, $maskSize); + if (PHP_INT_SIZE === 4) { + if ($maskSize === 64) { + $value = -1; + } elseif ($maskSize === 63) { + $value = PHP_INT_MAX; + } else { + $value = (1 << $maskSize - 32) - 1; + } + // as long as we support 32bit PHP we cannot use the `P` pack formatter (and not overflow 32bit integer) + $mask = pack('VVVV', -1, $value, 0, 0); + } else { + $mask = pack('VVP', (1 << 32) - 1, (1 << $maskSize - 32) - 1, 0); } - return \inet_ntop($binary).'/'.$maskBits; + + $binary = \inet_pton($ip); + return inet_ntop($binary & $mask) . '/' . $maskSize; } /** - * Gets either the /32 (IPv4) or the /128 (IPv6) subnet of an IP address + * Returns the IPv4 address embedded in an IPv6 if applicable. + * The detected format is "::ffff:x.x.x.x" using the binary form. * - * @return string + * @return string|null embedded IPv4 string or null if none was found + */ + private function getEmbeddedIpv4(string $ipv6): ?string { + $binary = inet_pton($ipv6); + if (!$binary) { + return null; + } + + $mask = inet_pton('::FFFF:FFFF'); + if (($binary & ~$mask) !== inet_pton('::FFFF:0.0.0.0')) { + return null; + } + + return inet_ntop(substr($binary, -4)); + } + + + /** + * Gets either the /32 (IPv4) or the /56 (default for IPv6) subnet of an IP address */ public function getSubnet(): string { - if (\preg_match('/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/', $this->ip)) { - return $this->getIPv4Subnet( - $this->ip, - 32 - ); + if (filter_var($this->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $this->ip . '/32'; } - return $this->getIPv6Subnet( - $this->ip, - 128 - ); + + $ipv4 = $this->getEmbeddedIpv4($this->ip); + if ($ipv4 !== null) { + return $ipv4 . '/32'; + } + + return $this->getIPv6Subnet($this->ip); } /** * Returns the specified IP address - * - * @return string */ public function __toString(): string { return $this->ip; 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); } diff --git a/lib/private/Security/RemoteHostValidator.php b/lib/private/Security/RemoteHostValidator.php new file mode 100644 index 00000000000..30bd59db2c1 --- /dev/null +++ b/lib/private/Security/RemoteHostValidator.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Security; + +use OC\Net\HostnameClassifier; +use OC\Net\IpAddressClassifier; +use OCP\IConfig; +use OCP\Security\IRemoteHostValidator; +use Psr\Log\LoggerInterface; +use function strtolower; +use function substr; +use function urldecode; + +/** + * @internal + */ +final class RemoteHostValidator implements IRemoteHostValidator { + public function __construct( + private IConfig $config, + private HostnameClassifier $hostnameClassifier, + private IpAddressClassifier $ipAddressClassifier, + private LoggerInterface $logger, + ) { + } + + public function isValid(string $host): bool { + if ($this->config->getSystemValueBool('allow_local_remote_servers', false)) { + return true; + } + + $host = idn_to_utf8(strtolower(urldecode($host))); + if ($host === false) { + return false; + } + + // Remove brackets from IPv6 addresses + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $host = substr($host, 1, -1); + } + + if ($this->hostnameClassifier->isLocalHostname($host) + || $this->ipAddressClassifier->isLocalAddress($host)) { + $this->logger->warning("Host $host was not connected to because it violates local access rules"); + return false; + } + + return true; + } +} diff --git a/lib/private/Security/SecureRandom.php b/lib/private/Security/SecureRandom.php index 815b70caa03..b2a3d19ce74 100644 --- a/lib/private/Security/SecureRandom.php +++ b/lib/private/Security/SecureRandom.php @@ -1,31 +1,11 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @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 AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security; use OCP\Security\ISecureRandom; @@ -36,19 +16,25 @@ use OCP\Security\ISecureRandom; * use a fallback. * * Usage: - * \OC::$server->getSecureRandom()->generate(10); + * \OC::$server->get(ISecureRandom::class)->generate(10); * @package OC\Security */ class SecureRandom implements ISecureRandom { /** - * Generate a random string of specified length. + * Generate a secure random string of specified length. * @param int $length The length of the generated string * @param string $characters An optional list of characters to use if no character list is - * specified all valid base64 characters are used. - * @return string + * specified all valid base64 characters are used. + * @throws \LengthException if an invalid length is requested */ - public function generate(int $length, - string $characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'): string { + public function generate( + int $length, + string $characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', + ): string { + if ($length <= 0) { + throw new \LengthException('Invalid length specified: ' . $length . ' must be bigger than 0'); + } + $maxCharIndex = \strlen($characters) - 1; $randomString = ''; diff --git a/lib/private/Security/Signature/Db/SignatoryMapper.php b/lib/private/Security/Signature/Db/SignatoryMapper.php new file mode 100644 index 00000000000..47b79320548 --- /dev/null +++ b/lib/private/Security/Signature/Db/SignatoryMapper.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Security\Signature\Db; + +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Model\Signatory; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<Signatory> + */ +class SignatoryMapper extends QBMapper { + public const TABLE = 'sec_signatory'; + + public function __construct( + IDBConnection $db, + ) { + parent::__construct($db, self::TABLE, Signatory::class); + } + + /** + * + */ + public function getByHost(string $host, string $account = ''): Signatory { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('host', $qb->createNamedParameter($host))) + ->andWhere($qb->expr()->eq('account', $qb->createNamedParameter($account))); + + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + throw new SignatoryNotFoundException('no signatory found'); + } + } + + /** + */ + public function getByKeyId(string $keyId): Signatory { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($keyId)))); + + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + throw new SignatoryNotFoundException('no signatory found'); + } + } + + /** + * @param string $keyId + * + * @return int + * @throws Exception + */ + public function deleteByKeyId(string $keyId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($keyId)))); + + return $qb->executeStatement(); + } + + /** + * @param Signatory $signatory + * + * @return int + */ + public function updateMetadata(Signatory $signatory): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('metadata', $qb->createNamedParameter(json_encode($signatory->getMetadata()))) + ->set('last_updated', $qb->createNamedParameter(time())); + $qb->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($signatory->getKeyId())))); + + return $qb->executeStatement(); + } + + /** + * @param Signatory $signator + */ + public function updatePublicKey(Signatory $signatory): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('signatory', $qb->createNamedParameter($signatory->getPublicKey())) + ->set('last_updated', $qb->createNamedParameter(time())); + $qb->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($signatory->getKeyId())))); + + return $qb->executeStatement(); + } + + /** + * returns a hash version for keyId for better index in the database + * + * @param string $keyId + * + * @return string + */ + private function hashKeyId(string $keyId): string { + return hash('sha256', $keyId); + } +} diff --git a/lib/private/Security/Signature/Model/IncomingSignedRequest.php b/lib/private/Security/Signature/Model/IncomingSignedRequest.php new file mode 100644 index 00000000000..0f7dc7cb771 --- /dev/null +++ b/lib/private/Security/Signature/Model/IncomingSignedRequest.php @@ -0,0 +1,268 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\Signature\Model; + +use JsonSerializable; +use NCU\Security\Signature\Enum\DigestAlgorithm; +use NCU\Security\Signature\Enum\SignatureAlgorithm; +use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\IncomingRequestException; +use NCU\Security\Signature\Exceptions\InvalidSignatureException; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureElementNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureException; +use NCU\Security\Signature\Exceptions\SignatureNotFoundException; +use NCU\Security\Signature\IIncomingSignedRequest; +use NCU\Security\Signature\ISignatureManager; +use NCU\Security\Signature\Model\Signatory; +use OC\Security\Signature\SignatureManager; +use OCP\IRequest; +use ValueError; + +/** + * @inheritDoc + * + * @see ISignatureManager for details on signature + * @since 31.0.0 + */ +class IncomingSignedRequest extends SignedRequest implements + IIncomingSignedRequest, + JsonSerializable { + private string $origin = ''; + + /** + * @param string $body + * @param IRequest $request + * @param array $options + * + * @throws IncomingRequestException if incoming request is wrongly signed + * @throws SignatureException if signature is faulty + * @throws SignatureNotFoundException if signature is not implemented + */ + public function __construct( + string $body, + private readonly IRequest $request, + private readonly array $options = [], + ) { + parent::__construct($body); + $this->verifyHeaders(); + $this->extractSignatureHeader(); + $this->reconstructSignatureData(); + + try { + // we set origin based on the keyId defined in the Signature header of the request + $this->setOrigin(Signatory::extractIdentityFromUri($this->getSigningElement('keyId'))); + } catch (IdentityNotFoundException $e) { + throw new IncomingRequestException($e->getMessage()); + } + } + + /** + * confirm that: + * + * - date is available in the header and its value is less than 5 minutes old + * - content-length is available and is the same as the payload size + * - digest is available and fit the checksum of the payload + * + * @throws IncomingRequestException + * @throws SignatureNotFoundException + */ + private function verifyHeaders(): void { + if ($this->request->getHeader('Signature') === '') { + throw new SignatureNotFoundException('missing Signature in header'); + } + + // confirm presence of date, content-length, digest and Signature + $date = $this->request->getHeader('date'); + if ($date === '') { + throw new IncomingRequestException('missing date in header'); + } + $contentLength = $this->request->getHeader('content-length'); + if ($contentLength === '') { + throw new IncomingRequestException('missing content-length in header'); + } + $digest = $this->request->getHeader('digest'); + if ($digest === '') { + throw new IncomingRequestException('missing digest in header'); + } + + // confirm date + try { + $dTime = new \DateTime($date); + $requestTime = $dTime->getTimestamp(); + } catch (\Exception) { + throw new IncomingRequestException('datetime exception'); + } + if ($requestTime < (time() - ($this->options['ttl'] ?? SignatureManager::DATE_TTL))) { + throw new IncomingRequestException('object is too old'); + } + + // confirm validity of content-length + if (strlen($this->getBody()) !== (int)$contentLength) { + throw new IncomingRequestException('inexact content-length in header'); + } + + // confirm digest value, based on body + [$algo, ] = explode('=', $digest); + try { + $this->setDigestAlgorithm(DigestAlgorithm::from($algo)); + } catch (ValueError) { + throw new IncomingRequestException('unknown digest algorithm'); + } + if ($digest !== $this->getDigest()) { + throw new IncomingRequestException('invalid value for digest in header'); + } + } + + /** + * extract data from the header entry 'Signature' and convert its content from string to an array + * also confirm that it contains the minimum mandatory information + * + * @throws IncomingRequestException + */ + private function extractSignatureHeader(): void { + $details = []; + foreach (explode(',', $this->request->getHeader('Signature')) as $entry) { + if ($entry === '' || !strpos($entry, '=')) { + continue; + } + + [$k, $v] = explode('=', $entry, 2); + preg_match('/^"([^"]+)"$/', $v, $var); + if ($var[0] !== '') { + $v = trim($var[0], '"'); + } + $details[$k] = $v; + } + + $this->setSigningElements($details); + + try { + // confirm keys are in the Signature header + $this->getSigningElement('keyId'); + $this->getSigningElement('headers'); + $this->setSignature($this->getSigningElement('signature')); + } catch (SignatureElementNotFoundException $e) { + throw new IncomingRequestException($e->getMessage()); + } + } + + /** + * reconstruct signature data based on signature's metadata stored in the 'Signature' header + * + * @throws SignatureException + * @throws SignatureElementNotFoundException + */ + private function reconstructSignatureData(): void { + $usedHeaders = explode(' ', $this->getSigningElement('headers')); + $neededHeaders = array_merge(['date', 'host', 'content-length', 'digest'], + array_keys($this->options['extraSignatureHeaders'] ?? [])); + + $missingHeaders = array_diff($neededHeaders, $usedHeaders); + if ($missingHeaders !== []) { + throw new SignatureException('missing entries in Signature.headers: ' . json_encode($missingHeaders)); + } + + $estimated = ['(request-target): ' . strtolower($this->request->getMethod()) . ' ' . $this->request->getRequestUri()]; + foreach ($usedHeaders as $key) { + if ($key === '(request-target)') { + continue; + } + $value = (strtolower($key) === 'host') ? $this->request->getServerHost() : $this->request->getHeader($key); + if ($value === '') { + throw new SignatureException('missing header ' . $key . ' in request'); + } + + $estimated[] = $key . ': ' . $value; + } + + $this->setSignatureData($estimated); + } + + /** + * @inheritDoc + * + * @return IRequest + * @since 31.0.0 + */ + public function getRequest(): IRequest { + return $this->request; + } + + /** + * set the hostname at the source of the request, + * based on the keyId defined in the signature header. + * + * @param string $origin + * @since 31.0.0 + */ + private function setOrigin(string $origin): void { + $this->origin = $origin; + } + + /** + * @inheritDoc + * + * @return string + * @throws IncomingRequestException + * @since 31.0.0 + */ + public function getOrigin(): string { + if ($this->origin === '') { + throw new IncomingRequestException('empty origin'); + } + return $this->origin; + } + + /** + * returns the keyId extracted from the signature headers. + * keyId is a mandatory entry in the headers of a signed request. + * + * @return string + * @throws SignatureElementNotFoundException + * @since 31.0.0 + */ + public function getKeyId(): string { + return $this->getSigningElement('keyId'); + } + + /** + * @inheritDoc + * + * @throws SignatureException + * @throws SignatoryNotFoundException + * @since 31.0.0 + */ + public function verify(): void { + $publicKey = $this->getSignatory()->getPublicKey(); + if ($publicKey === '') { + throw new SignatoryNotFoundException('empty public key'); + } + + $algorithm = SignatureAlgorithm::tryFrom($this->getSigningElement('algorithm')) ?? SignatureAlgorithm::RSA_SHA256; + if (openssl_verify( + implode("\n", $this->getSignatureData()), + base64_decode($this->getSignature()), + $publicKey, + $algorithm->value + ) !== 1) { + throw new InvalidSignatureException('signature issue'); + } + } + + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'options' => $this->options, + 'origin' => $this->origin, + ] + ); + } +} diff --git a/lib/private/Security/Signature/Model/OutgoingSignedRequest.php b/lib/private/Security/Signature/Model/OutgoingSignedRequest.php new file mode 100644 index 00000000000..dbfac3bfd34 --- /dev/null +++ b/lib/private/Security/Signature/Model/OutgoingSignedRequest.php @@ -0,0 +1,229 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\Signature\Model; + +use JsonSerializable; +use NCU\Security\Signature\Enum\DigestAlgorithm; +use NCU\Security\Signature\Enum\SignatureAlgorithm; +use NCU\Security\Signature\Exceptions\SignatoryException; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\IOutgoingSignedRequest; +use NCU\Security\Signature\ISignatoryManager; +use NCU\Security\Signature\ISignatureManager; +use OC\Security\Signature\SignatureManager; + +/** + * extends ISignedRequest to add info requested at the generation of the signature + * + * @see ISignatureManager for details on signature + * @since 31.0.0 + */ +class OutgoingSignedRequest extends SignedRequest implements + IOutgoingSignedRequest, + JsonSerializable { + private string $host = ''; + private array $headers = []; + /** @var list<string> $headerList */ + private array $headerList = []; + private SignatureAlgorithm $algorithm; + public function __construct( + string $body, + ISignatoryManager $signatoryManager, + private readonly string $identity, + private readonly string $method, + private readonly string $path, + ) { + parent::__construct($body); + + $options = $signatoryManager->getOptions(); + $this->setHost($identity) + ->setAlgorithm($options['algorithm'] ?? SignatureAlgorithm::RSA_SHA256) + ->setSignatory($signatoryManager->getLocalSignatory()) + ->setDigestAlgorithm($options['digestAlgorithm'] ?? DigestAlgorithm::SHA256); + + $headers = array_merge([ + '(request-target)' => strtolower($method) . ' ' . $path, + 'content-length' => strlen($this->getBody()), + 'date' => gmdate($options['dateHeader'] ?? SignatureManager::DATE_HEADER), + 'digest' => $this->getDigest(), + 'host' => $this->getHost() + ], $options['extraSignatureHeaders'] ?? []); + + $signing = $headerList = []; + foreach ($headers as $element => $value) { + $signing[] = $element . ': ' . $value; + $headerList[] = $element; + if ($element !== '(request-target)') { + $this->addHeader($element, $value); + } + } + + $this->setHeaderList($headerList) + ->setSignatureData($signing); + } + + /** + * @inheritDoc + * + * @param string $host + * @return $this + * @since 31.0.0 + */ + public function setHost(string $host): self { + $this->host = $host; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getHost(): string { + return $this->host; + } + + /** + * @inheritDoc + * + * @param string $key + * @param string|int|float $value + * + * @return self + * @since 31.0.0 + */ + public function addHeader(string $key, string|int|float $value): self { + $this->headers[$key] = $value; + return $this; + } + + /** + * @inheritDoc + * + * @return array + * @since 31.0.0 + */ + public function getHeaders(): array { + return $this->headers; + } + + /** + * set the ordered list of used headers in the Signature + * + * @param list<string> $list + * + * @return self + * @since 31.0.0 + */ + public function setHeaderList(array $list): self { + $this->headerList = $list; + return $this; + } + + /** + * returns ordered list of used headers in the Signature + * + * @return list<string> + * @since 31.0.0 + */ + public function getHeaderList(): array { + return $this->headerList; + } + + /** + * @inheritDoc + * + * @param SignatureAlgorithm $algorithm + * + * @return self + * @since 31.0.0 + */ + public function setAlgorithm(SignatureAlgorithm $algorithm): self { + $this->algorithm = $algorithm; + return $this; + } + + /** + * @inheritDoc + * + * @return SignatureAlgorithm + * @since 31.0.0 + */ + public function getAlgorithm(): SignatureAlgorithm { + return $this->algorithm; + } + + /** + * @inheritDoc + * + * @return self + * @throws SignatoryException + * @throws SignatoryNotFoundException + * @since 31.0.0 + */ + public function sign(): self { + $privateKey = $this->getSignatory()->getPrivateKey(); + if ($privateKey === '') { + throw new SignatoryException('empty private key'); + } + + openssl_sign( + implode("\n", $this->getSignatureData()), + $signed, + $privateKey, + $this->getAlgorithm()->value + ); + + $this->setSignature(base64_encode($signed)); + $this->setSigningElements( + [ + 'keyId="' . $this->getSignatory()->getKeyId() . '"', + 'algorithm="' . $this->getAlgorithm()->value . '"', + 'headers="' . implode(' ', $this->getHeaderList()) . '"', + 'signature="' . $this->getSignature() . '"' + ] + ); + $this->addHeader('Signature', implode(',', $this->getSigningElements())); + + return $this; + } + + /** + * @param string $clear + * @param string $privateKey + * @param SignatureAlgorithm $algorithm + * + * @return string + * @throws SignatoryException + */ + private function signString(string $clear, string $privateKey, SignatureAlgorithm $algorithm): string { + if ($privateKey === '') { + throw new SignatoryException('empty private key'); + } + + openssl_sign($clear, $signed, $privateKey, $algorithm->value); + + return base64_encode($signed); + } + + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'host' => $this->host, + 'headers' => $this->headers, + 'algorithm' => $this->algorithm->value, + 'method' => $this->method, + 'identity' => $this->identity, + 'path' => $this->path, + ] + ); + } +} diff --git a/lib/private/Security/Signature/Model/SignedRequest.php b/lib/private/Security/Signature/Model/SignedRequest.php new file mode 100644 index 00000000000..12a43f32bcc --- /dev/null +++ b/lib/private/Security/Signature/Model/SignedRequest.php @@ -0,0 +1,216 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\Signature\Model; + +use JsonSerializable; +use NCU\Security\Signature\Enum\DigestAlgorithm; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureElementNotFoundException; +use NCU\Security\Signature\ISignedRequest; +use NCU\Security\Signature\Model\Signatory; + +/** + * @inheritDoc + * + * @since 31.0.0 + */ +class SignedRequest implements ISignedRequest, JsonSerializable { + private string $digest = ''; + private DigestAlgorithm $digestAlgorithm = DigestAlgorithm::SHA256; + private array $signingElements = []; + private array $signatureData = []; + private string $signature = ''; + private ?Signatory $signatory = null; + + public function __construct( + private readonly string $body, + ) { + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getBody(): string { + return $this->body; + } + + /** + * set algorithm used to generate digest + * + * @param DigestAlgorithm $algorithm + * + * @return self + * @since 31.0.0 + */ + protected function setDigestAlgorithm(DigestAlgorithm $algorithm): self { + $this->digestAlgorithm = $algorithm; + return $this; + } + + /** + * @inheritDoc + * + * @return DigestAlgorithm + * @since 31.0.0 + */ + public function getDigestAlgorithm(): DigestAlgorithm { + return $this->digestAlgorithm; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getDigest(): string { + if ($this->digest === '') { + $this->digest = $this->digestAlgorithm->value . '=' + . base64_encode(hash($this->digestAlgorithm->getHashingAlgorithm(), $this->body, true)); + } + return $this->digest; + } + + /** + * @inheritDoc + * + * @param array $elements + * + * @return self + * @since 31.0.0 + */ + public function setSigningElements(array $elements): self { + $this->signingElements = $elements; + return $this; + } + + /** + * @inheritDoc + * + * @return array + * @since 31.0.0 + */ + public function getSigningElements(): array { + return $this->signingElements; + } + + /** + * @param string $key + * + * @return string + * @throws SignatureElementNotFoundException + * @since 31.0.0 + * + */ + public function getSigningElement(string $key): string { // getSignatureDetail / getSignatureEntry() ? + if (!array_key_exists($key, $this->signingElements)) { + throw new SignatureElementNotFoundException('missing element ' . $key . ' in Signature header'); + } + + return $this->signingElements[$key]; + } + + /** + * store data used to generate signature + * + * @param array $data + * + * @return self + * @since 31.0.0 + */ + protected function setSignatureData(array $data): self { + $this->signatureData = $data; + return $this; + } + + /** + * @inheritDoc + * + * @return array + * @since 31.0.0 + */ + public function getSignatureData(): array { + return $this->signatureData; + } + + /** + * set the signed version of the signature + * + * @param string $signature + * + * @return self + * @since 31.0.0 + */ + protected function setSignature(string $signature): self { + $this->signature = $signature; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getSignature(): string { + return $this->signature; + } + + /** + * @inheritDoc + * + * @param Signatory $signatory + * @return self + * @since 31.0.0 + */ + public function setSignatory(Signatory $signatory): self { + $this->signatory = $signatory; + return $this; + } + + /** + * @inheritDoc + * + * @return Signatory + * @throws SignatoryNotFoundException + * @since 31.0.0 + */ + public function getSignatory(): Signatory { + if ($this->signatory === null) { + throw new SignatoryNotFoundException(); + } + + return $this->signatory; + } + + /** + * @inheritDoc + * + * @return bool + * @since 31.0.0 + */ + public function hasSignatory(): bool { + return ($this->signatory !== null); + } + + public function jsonSerialize(): array { + return [ + 'body' => $this->body, + 'digest' => $this->getDigest(), + 'digestAlgorithm' => $this->getDigestAlgorithm()->value, + 'signingElements' => $this->signingElements, + 'signatureData' => $this->signatureData, + 'signature' => $this->signature, + 'signatory' => $this->signatory ?? false, + ]; + } +} diff --git a/lib/private/Security/Signature/SignatureManager.php b/lib/private/Security/Signature/SignatureManager.php new file mode 100644 index 00000000000..91a06e29b4a --- /dev/null +++ b/lib/private/Security/Signature/SignatureManager.php @@ -0,0 +1,426 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Security\Signature; + +use NCU\Security\Signature\Enum\SignatoryType; +use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\IncomingRequestException; +use NCU\Security\Signature\Exceptions\InvalidKeyOriginException; +use NCU\Security\Signature\Exceptions\InvalidSignatureException; +use NCU\Security\Signature\Exceptions\SignatoryConflictException; +use NCU\Security\Signature\Exceptions\SignatoryException; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureElementNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureException; +use NCU\Security\Signature\Exceptions\SignatureNotFoundException; +use NCU\Security\Signature\IIncomingSignedRequest; +use NCU\Security\Signature\IOutgoingSignedRequest; +use NCU\Security\Signature\ISignatoryManager; +use NCU\Security\Signature\ISignatureManager; +use NCU\Security\Signature\Model\Signatory; +use OC\Security\Signature\Db\SignatoryMapper; +use OC\Security\Signature\Model\IncomingSignedRequest; +use OC\Security\Signature\Model\OutgoingSignedRequest; +use OCP\DB\Exception as DBException; +use OCP\IAppConfig; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +/** + * ISignatureManager is a service integrated to core that provide tools + * to set/get authenticity of/from outgoing/incoming request. + * + * Quick description of the signature, added to the headers + * { + * "(request-target)": "post /path", + * "content-length": 385, + * "date": "Mon, 08 Jul 2024 14:16:20 GMT", + * "digest": "SHA-256=U7gNVUQiixe5BRbp4Tg0xCZMTcSWXXUZI2\\/xtHM40S0=", + * "host": "hostname.of.the.recipient", + * "Signature": "keyId=\"https://author.hostname/key\",algorithm=\"sha256\",headers=\"content-length + * date digest host\",signature=\"DzN12OCS1rsA[...]o0VmxjQooRo6HHabg==\"" + * } + * + * 'content-length' is the total length of the data/content + * 'date' is the datetime the request have been initiated + * 'digest' is a checksum of the data/content + * 'host' is the hostname of the recipient of the request (remote when signing outgoing request, local on + * incoming request) + * 'Signature' contains the signature generated using the private key, and metadata: + * - 'keyId' is a unique id, formatted as an url. hostname is used to retrieve the public key via custom + * discovery + * - 'algorithm' define the algorithm used to generate signature + * - 'headers' contains a list of element used during the generation of the signature + * - 'signature' is the encrypted string, using local private key, of an array containing elements + * listed in 'headers' and their value. Some elements (content-length date digest host) are mandatory + * to ensure authenticity override protection. + * + * @since 31.0.0 + */ +class SignatureManager implements ISignatureManager { + public const DATE_HEADER = 'D, d M Y H:i:s T'; + public const DATE_TTL = 300; + public const SIGNATORY_TTL = 86400 * 3; + public const BODY_MAXSIZE = 50000; // max size of the payload of the request + public const APPCONFIG_IDENTITY = 'security.signature.identity'; + + public function __construct( + private readonly IRequest $request, + private readonly SignatoryMapper $mapper, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + } + + /** + * @inheritDoc + * + * @param ISignatoryManager $signatoryManager used to get details about remote instance + * @param string|null $body if NULL, body will be extracted from php://input + * + * @return IIncomingSignedRequest + * @throws IncomingRequestException if anything looks wrong with the incoming request + * @throws SignatureNotFoundException if incoming request is not signed + * @throws SignatureException if signature could not be confirmed + * @since 31.0.0 + */ + public function getIncomingSignedRequest( + ISignatoryManager $signatoryManager, + ?string $body = null, + ): IIncomingSignedRequest { + $body = $body ?? file_get_contents('php://input'); + $options = $signatoryManager->getOptions(); + if (strlen($body) > ($options['bodyMaxSize'] ?? self::BODY_MAXSIZE)) { + throw new IncomingRequestException('content of request is too big'); + } + + // generate IncomingSignedRequest based on body and request + $signedRequest = new IncomingSignedRequest($body, $this->request, $options); + + try { + // confirm the validity of content and identity of the incoming request + $this->confirmIncomingRequestSignature($signedRequest, $signatoryManager, $options['ttlSignatory'] ?? self::SIGNATORY_TTL); + } catch (SignatureException $e) { + $this->logger->warning( + 'signature could not be verified', [ + 'exception' => $e, + 'signedRequest' => $signedRequest, + 'signatoryManager' => get_class($signatoryManager) + ] + ); + throw $e; + } + + return $signedRequest; + } + + /** + * confirm that the Signature is signed using the correct private key, using + * clear version of the Signature and the public key linked to the keyId + * + * @param IIncomingSignedRequest $signedRequest + * @param ISignatoryManager $signatoryManager + * + * @throws SignatoryNotFoundException + * @throws SignatureException + */ + private function confirmIncomingRequestSignature( + IIncomingSignedRequest $signedRequest, + ISignatoryManager $signatoryManager, + int $ttlSignatory, + ): void { + $knownSignatory = null; + try { + $knownSignatory = $this->getStoredSignatory($signedRequest->getKeyId()); + // refreshing ttl and compare with previous public key + if ($ttlSignatory > 0 && $knownSignatory->getLastUpdated() < (time() - $ttlSignatory)) { + $signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest); + $this->updateSignatoryMetadata($signatory); + $knownSignatory->setMetadata($signatory->getMetadata() ?? []); + } + + $signedRequest->setSignatory($knownSignatory); + $signedRequest->verify(); + } catch (InvalidKeyOriginException $e) { + throw $e; // issue while requesting remote instance also means there is no 2nd try + } catch (SignatoryNotFoundException) { + // if no signatory in cache, we retrieve the one from the remote instance (using + // $signatoryManager), check its validity with current signature and store it + $signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest); + $signedRequest->setSignatory($signatory); + $signedRequest->verify(); + $this->storeSignatory($signatory); + } catch (SignatureException) { + // if public key (from cache) is not valid, we try to refresh it (based on SignatoryType) + try { + $signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest); + } catch (SignatoryNotFoundException $e) { + $this->manageDeprecatedSignatory($knownSignatory); + throw $e; + } + + $signedRequest->setSignatory($signatory); + try { + $signedRequest->verify(); + } catch (InvalidSignatureException $e) { + $this->logger->debug('signature issue', ['signed' => $signedRequest, 'exception' => $e]); + throw $e; + } + + $this->storeSignatory($signatory); + } + } + + /** + * @inheritDoc + * + * @param ISignatoryManager $signatoryManager + * @param string $content body to be signed + * @param string $method needed in the signature + * @param string $uri needed in the signature + * + * @return IOutgoingSignedRequest + * @throws IdentityNotFoundException + * @throws SignatoryException + * @throws SignatoryNotFoundException + * @since 31.0.0 + */ + public function getOutgoingSignedRequest( + ISignatoryManager $signatoryManager, + string $content, + string $method, + string $uri, + ): IOutgoingSignedRequest { + $signedRequest = new OutgoingSignedRequest( + $content, + $signatoryManager, + $this->extractIdentityFromUri($uri), + $method, + parse_url($uri, PHP_URL_PATH) ?? '/' + ); + + $signedRequest->sign(); + + return $signedRequest; + } + + /** + * @inheritDoc + * + * @param ISignatoryManager $signatoryManager + * @param array $payload original payload, will be used to sign and completed with new headers with + * signature elements + * @param string $method needed in the signature + * @param string $uri needed in the signature + * + * @return array new payload to be sent, including original payload and signature elements in headers + * @since 31.0.0 + */ + public function signOutgoingRequestIClientPayload( + ISignatoryManager $signatoryManager, + array $payload, + string $method, + string $uri, + ): array { + $signedRequest = $this->getOutgoingSignedRequest($signatoryManager, $payload['body'], $method, $uri); + $payload['headers'] = array_merge($payload['headers'], $signedRequest->getHeaders()); + + return $payload; + } + + /** + * @inheritDoc + * + * @param string $host remote host + * @param string $account linked account, should be used when multiple signature can exist for the same + * host + * + * @return Signatory + * @throws SignatoryNotFoundException if entry does not exist in local database + * @since 31.0.0 + */ + public function getSignatory(string $host, string $account = ''): Signatory { + return $this->mapper->getByHost($host, $account); + } + + + /** + * @inheritDoc + * + * keyId is set using app config 'core/security.signature.identity' + * + * @param string $path + * + * @return string + * @throws IdentityNotFoundException is identity is not set in app config + * @since 31.0.0 + */ + public function generateKeyIdFromConfig(string $path): string { + if (!$this->appConfig->hasKey('core', self::APPCONFIG_IDENTITY, true)) { + throw new IdentityNotFoundException(self::APPCONFIG_IDENTITY . ' not set'); + } + + $identity = trim($this->appConfig->getValueString('core', self::APPCONFIG_IDENTITY, lazy: true), '/'); + + return 'https://' . $identity . '/' . ltrim($path, '/'); + } + + /** + * @inheritDoc + * + * @param string $uri + * + * @return string + * @throws IdentityNotFoundException if identity cannot be extracted + * @since 31.0.0 + */ + public function extractIdentityFromUri(string $uri): string { + return Signatory::extractIdentityFromUri($uri); + } + + /** + * get remote signatory using the ISignatoryManager + * and confirm the validity of the keyId + * + * @param ISignatoryManager $signatoryManager + * @param IIncomingSignedRequest $signedRequest + * + * @return Signatory + * @throws InvalidKeyOriginException + * @throws SignatoryNotFoundException + * @see ISignatoryManager::getRemoteSignatory + */ + private function getSaneRemoteSignatory( + ISignatoryManager $signatoryManager, + IIncomingSignedRequest $signedRequest, + ): Signatory { + $signatory = $signatoryManager->getRemoteSignatory($signedRequest->getOrigin()); + if ($signatory === null) { + throw new SignatoryNotFoundException('empty result from getRemoteSignatory'); + } + try { + if ($signatory->getKeyId() !== $signedRequest->getKeyId()) { + throw new InvalidKeyOriginException('keyId from signatory not related to the one from request'); + } + } catch (SignatureElementNotFoundException) { + throw new InvalidKeyOriginException('missing keyId'); + } + $signatory->setProviderId($signatoryManager->getProviderId()); + + return $signatory; + } + + /** + * @param string $keyId + * + * @return Signatory + * @throws SignatoryNotFoundException + */ + private function getStoredSignatory(string $keyId): Signatory { + return $this->mapper->getByKeyId($keyId); + } + + /** + * @param Signatory $signatory + */ + private function storeSignatory(Signatory $signatory): void { + try { + $this->insertSignatory($signatory); + } catch (DBException $e) { + if ($e->getReason() !== DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + $this->logger->warning('exception while storing signature', ['exception' => $e]); + throw $e; + } + + try { + $this->updateKnownSignatory($signatory); + } catch (SignatoryNotFoundException $e) { + $this->logger->warning('strange behavior, signatory not found ?', ['exception' => $e]); + } + } + } + + /** + * @param Signatory $signatory + */ + private function insertSignatory(Signatory $signatory): void { + $time = time(); + $signatory->setCreation($time); + $signatory->setLastUpdated($time); + $signatory->setMetadata($signatory->getMetadata() ?? []); // trigger insert on field metadata using current or default value + $this->mapper->insert($signatory); + } + + /** + * @param Signatory $signatory + * + * @throws SignatoryNotFoundException + * @throws SignatoryConflictException + */ + private function updateKnownSignatory(Signatory $signatory): void { + $knownSignatory = $this->getStoredSignatory($signatory->getKeyId()); + switch ($signatory->getType()) { + case SignatoryType::FORGIVABLE: + $this->deleteSignatory($knownSignatory->getKeyId()); + $this->insertSignatory($signatory); + return; + + case SignatoryType::REFRESHABLE: + $this->updateSignatoryPublicKey($signatory); + $this->updateSignatoryMetadata($signatory); + break; + + case SignatoryType::TRUSTED: + // TODO: send notice to admin + throw new SignatoryConflictException(); + + case SignatoryType::STATIC: + // TODO: send warning to admin + throw new SignatoryConflictException(); + } + } + + /** + * This is called when a remote signatory does not exist anymore + * + * @param Signatory|null $knownSignatory NULL is not known + * + * @throws SignatoryConflictException + * @throws SignatoryNotFoundException + */ + private function manageDeprecatedSignatory(?Signatory $knownSignatory): void { + switch ($knownSignatory?->getType()) { + case null: // unknown in local database + case SignatoryType::FORGIVABLE: // who cares ? + throw new SignatoryNotFoundException(); // meaning we just return the correct exception + + case SignatoryType::REFRESHABLE: + // TODO: send notice to admin + throw new SignatoryConflictException(); // while it can be refreshed, it must exist + + case SignatoryType::TRUSTED: + case SignatoryType::STATIC: + // TODO: send warning to admin + throw new SignatoryConflictException(); // no way. + } + } + + + private function updateSignatoryPublicKey(Signatory $signatory): void { + $this->mapper->updatePublicKey($signatory); + } + + private function updateSignatoryMetadata(Signatory $signatory): void { + $this->mapper->updateMetadata($signatory); + } + + private function deleteSignatory(string $keyId): void { + $this->mapper->deleteByKeyId($keyId); + } +} diff --git a/lib/private/Security/TrustedDomainHelper.php b/lib/private/Security/TrustedDomainHelper.php index 8004bf7dc6f..a65779780e8 100644 --- a/lib/private/Security/TrustedDomainHelper.php +++ b/lib/private/Security/TrustedDomainHelper.php @@ -1,57 +1,28 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Johannes Ernst <jernst@indiecomputing.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Security; use OC\AppFramework\Http\Request; use OCP\IConfig; +use OCP\Security\ITrustedDomainHelper; -/** - * Class TrustedDomain - * - * @package OC\Security - */ -class TrustedDomainHelper { - /** @var IConfig */ - private $config; - - /** - * @param IConfig $config - */ - public function __construct(IConfig $config) { - $this->config = $config; +class TrustedDomainHelper implements ITrustedDomainHelper { + public function __construct( + private IConfig $config, + ) { } /** * Strips a potential port from a domain (in format domain:port) - * @param string $host * @return string $host without appended port */ - private function getDomainWithoutPort($host) { + private function getDomainWithoutPort(string $host): string { $pos = strrpos($host, ':'); if ($pos !== false) { $port = substr($host, $pos + 1); @@ -63,15 +34,25 @@ class TrustedDomainHelper { } /** - * Checks whether a domain is considered as trusted from the list - * of trusted domains. If no trusted domains have been configured, returns - * true. - * This is used to prevent Host Header Poisoning. - * @param string $domainWithPort - * @return bool true if the given domain is trusted or if no trusted domains - * have been configured + * {@inheritDoc} + */ + public function isTrustedUrl(string $url): bool { + $parsedUrl = parse_url($url); + if (empty($parsedUrl['host'])) { + return false; + } + + if (isset($parsedUrl['port']) && $parsedUrl['port']) { + return $this->isTrustedDomain($parsedUrl['host'] . ':' . $parsedUrl['port']); + } + + return $this->isTrustedDomain($parsedUrl['host']); + } + + /** + * {@inheritDoc} */ - public function isTrustedDomain($domainWithPort) { + public function isTrustedDomain(string $domainWithPort): bool { // overwritehost is always trusted if ($this->config->getSystemValue('overwritehost') !== '') { return true; @@ -89,8 +70,8 @@ class TrustedDomainHelper { if (preg_match(Request::REGEX_LOCALHOST, $domain) === 1) { return true; } - // Reject misformed domains in any case - if (strpos($domain,'-') === 0 || strpos($domain,'..') !== false) { + // Reject malformed domains in any case + if (str_starts_with($domain, '-') || str_contains($domain, '..')) { return false; } // Match, allowing for * wildcards diff --git a/lib/private/Security/VerificationToken/CleanUpJob.php b/lib/private/Security/VerificationToken/CleanUpJob.php new file mode 100644 index 00000000000..ba8f4352f80 --- /dev/null +++ b/lib/private/Security/VerificationToken/CleanUpJob.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\VerificationToken; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\Job; +use OCP\IConfig; +use OCP\IUserManager; +use OCP\Security\VerificationToken\InvalidTokenException; +use OCP\Security\VerificationToken\IVerificationToken; + +class CleanUpJob extends Job { + protected ?int $runNotBefore = null; + protected ?string $userId = null; + protected ?string $subject = null; + protected ?string $pwdPrefix = null; + + public function __construct( + ITimeFactory $time, + private IConfig $config, + private IVerificationToken $verificationToken, + private IUserManager $userManager, + ) { + parent::__construct($time); + } + + public function setArgument($argument): void { + parent::setArgument($argument); + $args = \json_decode($argument, true); + $this->userId = (string)$args['userId']; + $this->subject = (string)$args['subject']; + $this->pwdPrefix = (string)$args['pp']; + $this->runNotBefore = (int)$args['notBefore']; + } + + protected function run($argument): void { + try { + $user = $this->userManager->get($this->userId); + if ($user === null) { + return; + } + $this->verificationToken->check('irrelevant', $user, $this->subject, $this->pwdPrefix); + } catch (InvalidTokenException $e) { + if ($e->getCode() === InvalidTokenException::TOKEN_EXPIRED) { + // make sure to only remove expired tokens + $this->config->deleteUserValue($this->userId, 'core', $this->subject); + } + } + } + + public function start(IJobList $jobList): void { + if ($this->time->getTime() >= $this->runNotBefore) { + $jobList->remove($this, $this->argument); + parent::start($jobList); + } + } +} diff --git a/lib/private/Security/VerificationToken/VerificationToken.php b/lib/private/Security/VerificationToken/VerificationToken.php new file mode 100644 index 00000000000..89f45180359 --- /dev/null +++ b/lib/private/Security/VerificationToken/VerificationToken.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\VerificationToken; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\IConfig; +use OCP\IUser; +use OCP\Security\ICrypto; +use OCP\Security\ISecureRandom; +use OCP\Security\VerificationToken\InvalidTokenException; +use OCP\Security\VerificationToken\IVerificationToken; +use function json_encode; + +class VerificationToken implements IVerificationToken { + protected const TOKEN_LIFETIME = 60 * 60 * 24 * 7; + + public function __construct( + private IConfig $config, + private ICrypto $crypto, + private ITimeFactory $timeFactory, + private ISecureRandom $secureRandom, + private IJobList $jobList, + ) { + } + + /** + * @throws InvalidTokenException + */ + protected function throwInvalidTokenException(int $code): void { + throw new InvalidTokenException($code); + } + + public function check( + string $token, + ?IUser $user, + string $subject, + string $passwordPrefix = '', + bool $expiresWithLogin = false, + ): void { + if ($user === null || !$user->isEnabled()) { + $this->throwInvalidTokenException(InvalidTokenException::USER_UNKNOWN); + } + + $encryptedToken = $this->config->getUserValue($user->getUID(), 'core', $subject, null); + if ($encryptedToken === null) { + $this->throwInvalidTokenException(InvalidTokenException::TOKEN_NOT_FOUND); + } + + try { + $decryptedToken = $this->crypto->decrypt($encryptedToken, $passwordPrefix . $this->config->getSystemValueString('secret')); + } catch (\Exception $e) { + // Retry with empty secret as a fallback for instances where the secret might not have been set by accident + try { + $decryptedToken = $this->crypto->decrypt($encryptedToken, $passwordPrefix); + } catch (\Exception $e2) { + $this->throwInvalidTokenException(InvalidTokenException::TOKEN_DECRYPTION_ERROR); + } + } + + $splitToken = explode(':', $decryptedToken); + if (count($splitToken) !== 2) { + $this->throwInvalidTokenException(InvalidTokenException::TOKEN_INVALID_FORMAT); + } + + if ($splitToken[0] < ($this->timeFactory->getTime() - self::TOKEN_LIFETIME) + || ($expiresWithLogin && $user->getLastLogin() > $splitToken[0])) { + $this->throwInvalidTokenException(InvalidTokenException::TOKEN_EXPIRED); + } + + if (!hash_equals($splitToken[1], $token)) { + $this->throwInvalidTokenException(InvalidTokenException::TOKEN_MISMATCH); + } + } + + public function create( + IUser $user, + string $subject, + string $passwordPrefix = '', + ): string { + $token = $this->secureRandom->generate( + 21, + ISecureRandom::CHAR_DIGITS + . ISecureRandom::CHAR_LOWER + . ISecureRandom::CHAR_UPPER + ); + $tokenValue = $this->timeFactory->getTime() . ':' . $token; + $encryptedValue = $this->crypto->encrypt($tokenValue, $passwordPrefix . $this->config->getSystemValueString('secret')); + $this->config->setUserValue($user->getUID(), 'core', $subject, $encryptedValue); + $jobArgs = json_encode([ + 'userId' => $user->getUID(), + 'subject' => $subject, + 'pp' => $passwordPrefix, + 'notBefore' => $this->timeFactory->getTime() + self::TOKEN_LIFETIME * 2, // multiply to provide a grace period + ]); + $this->jobList->add(CleanUpJob::class, $jobArgs); + + return $token; + } + + public function delete(string $token, IUser $user, string $subject): void { + $this->config->deleteUserValue($user->getUID(), 'core', $subject); + } +} |