diff options
author | Joas Schilling <213943+nickvergessen@users.noreply.github.com> | 2025-01-27 14:20:23 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-01-27 14:20:23 +0100 |
commit | 510d897086bfc0dbab6c9e092b2acf4008601a1f (patch) | |
tree | a73a8bc0952a468d98dcadbe7455145caf335f59 | |
parent | f5cd0cbd7007e42635914f3acc69d5e85b0b5907 (diff) | |
parent | c1655bcde75a125fde9a92877907a10ed25297a3 (diff) | |
download | nextcloud-server-510d897086bfc0dbab6c9e092b2acf4008601a1f.tar.gz nextcloud-server-510d897086bfc0dbab6c9e092b2acf4008601a1f.zip |
Merge pull request #50234 from nextcloud/bugfix/noid/allow-ratelimit-bypass
fix(ratelimit): Allow to bypass rate-limit from bruteforce allowlist
-rw-r--r-- | build/psalm-baseline.xml | 5 | ||||
-rw-r--r-- | lib/composer/composer/autoload_classmap.php | 1 | ||||
-rw-r--r-- | lib/composer/composer/autoload_static.php | 1 | ||||
-rw-r--r-- | lib/private/AppFramework/DependencyInjection/DIContainer.php | 10 | ||||
-rw-r--r-- | lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php | 9 | ||||
-rw-r--r-- | lib/private/Security/Bruteforce/Throttler.php | 69 | ||||
-rw-r--r-- | lib/private/Security/Ip/BruteforceAllowList.php | 60 | ||||
-rw-r--r-- | tests/lib/AppFramework/Middleware/Security/RateLimitingMiddlewareTest.php | 10 | ||||
-rw-r--r-- | tests/lib/Security/Bruteforce/ThrottlerTest.php | 212 | ||||
-rw-r--r-- | tests/lib/Security/Ip/BruteforceAllowListTest.php | 162 |
10 files changed, 246 insertions, 293 deletions
diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index 49ce3c7b054..fe3a17ab896 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -2358,11 +2358,6 @@ <code><![CDATA[$this->collectionName]]></code> </NullableReturnStatement> </file> - <file src="lib/private/Security/Bruteforce/Throttler.php"> - <NullArgument> - <code><![CDATA[null]]></code> - </NullArgument> - </file> <file src="lib/private/Security/CSP/ContentSecurityPolicyNonceManager.php"> <NoInterfaceProperties> <code><![CDATA[$this->request->server]]></code> diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index b589690bc10..cbadc2deb15 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1943,6 +1943,7 @@ return array( 'OC\\Security\\IdentityProof\\Manager' => $baseDir . '/lib/private/Security/IdentityProof/Manager.php', 'OC\\Security\\IdentityProof\\Signer' => $baseDir . '/lib/private/Security/IdentityProof/Signer.php', 'OC\\Security\\Ip\\Address' => $baseDir . '/lib/private/Security/Ip/Address.php', + 'OC\\Security\\Ip\\BruteforceAllowList' => $baseDir . '/lib/private/Security/Ip/BruteforceAllowList.php', 'OC\\Security\\Ip\\Factory' => $baseDir . '/lib/private/Security/Ip/Factory.php', 'OC\\Security\\Ip\\Range' => $baseDir . '/lib/private/Security/Ip/Range.php', 'OC\\Security\\Ip\\RemoteAddress' => $baseDir . '/lib/private/Security/Ip/RemoteAddress.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 65c8ae215dc..10086cabce0 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1992,6 +1992,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Security\\IdentityProof\\Manager' => __DIR__ . '/../../..' . '/lib/private/Security/IdentityProof/Manager.php', 'OC\\Security\\IdentityProof\\Signer' => __DIR__ . '/../../..' . '/lib/private/Security/IdentityProof/Signer.php', 'OC\\Security\\Ip\\Address' => __DIR__ . '/../../..' . '/lib/private/Security/Ip/Address.php', + 'OC\\Security\\Ip\\BruteforceAllowList' => __DIR__ . '/../../..' . '/lib/private/Security/Ip/BruteforceAllowList.php', 'OC\\Security\\Ip\\Factory' => __DIR__ . '/../../..' . '/lib/private/Security/Ip/Factory.php', 'OC\\Security\\Ip\\Range' => __DIR__ . '/../../..' . '/lib/private/Security/Ip/Range.php', 'OC\\Security\\Ip\\RemoteAddress' => __DIR__ . '/../../..' . '/lib/private/Security/Ip/RemoteAddress.php', diff --git a/lib/private/AppFramework/DependencyInjection/DIContainer.php b/lib/private/AppFramework/DependencyInjection/DIContainer.php index 2083a0e7195..be4f6897da8 100644 --- a/lib/private/AppFramework/DependencyInjection/DIContainer.php +++ b/lib/private/AppFramework/DependencyInjection/DIContainer.php @@ -274,15 +274,7 @@ class DIContainer extends SimpleContainer implements IAppContainer { $c->get(LoggerInterface::class) ) ); - $dispatcher->registerMiddleware( - new RateLimitingMiddleware( - $c->get(IRequest::class), - $c->get(IUserSession::class), - $c->get(IControllerMethodReflector::class), - $c->get(OC\Security\RateLimiting\Limiter::class), - $c->get(ISession::class) - ) - ); + $dispatcher->registerMiddleware($c->get(RateLimitingMiddleware::class)); $dispatcher->registerMiddleware( new OC\AppFramework\Middleware\PublicShare\PublicShareMiddleware( $c->get(IRequest::class), diff --git a/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php b/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php index f4d120ebc30..2d19be97993 100644 --- a/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php +++ b/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php @@ -9,6 +9,7 @@ declare(strict_types=1); namespace OC\AppFramework\Middleware\Security; use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Security\Ip\BruteforceAllowList; use OC\Security\RateLimiting\Exception\RateLimitExceededException; use OC\Security\RateLimiting\Limiter; use OC\User\Session; @@ -20,6 +21,7 @@ use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Middleware; +use OCP\IAppConfig; use OCP\IRequest; use OCP\ISession; use OCP\IUserSession; @@ -53,6 +55,8 @@ class RateLimitingMiddleware extends Middleware { protected ControllerMethodReflector $reflector, protected Limiter $limiter, protected ISession $session, + protected IAppConfig $appConfig, + protected BruteforceAllowList $bruteForceAllowList, ) { } @@ -73,6 +77,11 @@ class RateLimitingMiddleware extends Middleware { $rateLimit = $this->readLimitFromAnnotationOrAttribute($controller, $methodName, 'UserRateThrottle', UserRateLimit::class); if ($rateLimit !== null) { + if ($this->appConfig->getValueBool('bruteforcesettings', 'apply_allowlist_to_ratelimit') + && $this->bruteForceAllowList->isBypassListed($this->request->getRemoteAddress())) { + return; + } + $this->limiter->registerUserRequest( $rateLimitIdentifier, $rateLimit->getLimit(), diff --git a/lib/private/Security/Bruteforce/Throttler.php b/lib/private/Security/Bruteforce/Throttler.php index 924ae3685f3..21d50848641 100644 --- a/lib/private/Security/Bruteforce/Throttler.php +++ b/lib/private/Security/Bruteforce/Throttler.php @@ -9,6 +9,7 @@ declare(strict_types=1); 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; @@ -32,14 +33,13 @@ use Psr\Log\LoggerInterface; class Throttler implements IThrottler { /** @var bool[] */ private array $hasAttemptsDeleted = []; - /** @var bool[] */ - private array $ipIsWhitelisted = []; public function __construct( private ITimeFactory $timeFactory, private LoggerInterface $logger, private IConfig $config, private IBackend $backend, + private BruteforceAllowList $allowList, ) { } @@ -83,70 +83,7 @@ class Throttler implements IThrottler { * Check if the IP is whitelisted */ public function isBypassListed(string $ip): bool { - if (isset($this->ipIsWhitelisted[$ip])) { - return $this->ipIsWhitelisted[$ip]; - } - - if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) { - $this->ipIsWhitelisted[$ip] = true; - return true; - } - - $keys = $this->config->getAppKeys('bruteForce'); - $keys = array_filter($keys, function ($key) { - return str_starts_with($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 { - $this->ipIsWhitelisted[$ip] = false; - 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) { - $this->ipIsWhitelisted[$ip] = true; - return true; - } - } - - $this->ipIsWhitelisted[$ip] = false; - return false; + return $this->allowList->isBypassListed($ip); } /** diff --git a/lib/private/Security/Ip/BruteforceAllowList.php b/lib/private/Security/Ip/BruteforceAllowList.php new file mode 100644 index 00000000000..cc4f0ceebe5 --- /dev/null +++ b/lib/private/Security/Ip/BruteforceAllowList.php @@ -0,0 +1,60 @@ +<?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; + } + + $keys = $this->appConfig->getKeys('bruteForce'); + $keys = array_filter($keys, static fn ($key): bool => str_starts_with($key, 'whitelist_')); + + foreach ($keys 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/tests/lib/AppFramework/Middleware/Security/RateLimitingMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/RateLimitingMiddlewareTest.php index fddca471215..c42baadcb1c 100644 --- a/tests/lib/AppFramework/Middleware/Security/RateLimitingMiddlewareTest.php +++ b/tests/lib/AppFramework/Middleware/Security/RateLimitingMiddlewareTest.php @@ -11,6 +11,7 @@ namespace Test\AppFramework\Middleware\Security; use OC\AppFramework\Middleware\Security\RateLimitingMiddleware; use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Security\Ip\BruteforceAllowList; use OC\Security\RateLimiting\Exception\RateLimitExceededException; use OC\Security\RateLimiting\Limiter; use OCP\AppFramework\Controller; @@ -18,6 +19,7 @@ use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\TemplateResponse; +use OCP\IAppConfig; use OCP\IRequest; use OCP\ISession; use OCP\IUser; @@ -61,6 +63,8 @@ class RateLimitingMiddlewareTest extends TestCase { private ControllerMethodReflector $reflector; private Limiter|MockObject $limiter; private ISession|MockObject $session; + private IAppConfig|MockObject $appConfig; + private BruteforceAllowList|MockObject $bruteForceAllowList; private RateLimitingMiddleware $rateLimitingMiddleware; protected function setUp(): void { @@ -71,13 +75,17 @@ class RateLimitingMiddlewareTest extends TestCase { $this->reflector = new ControllerMethodReflector(); $this->limiter = $this->createMock(Limiter::class); $this->session = $this->createMock(ISession::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->bruteForceAllowList = $this->createMock(BruteforceAllowList::class); $this->rateLimitingMiddleware = new RateLimitingMiddleware( $this->request, $this->userSession, $this->reflector, $this->limiter, - $this->session + $this->session, + $this->appConfig, + $this->bruteForceAllowList, ); } diff --git a/tests/lib/Security/Bruteforce/ThrottlerTest.php b/tests/lib/Security/Bruteforce/ThrottlerTest.php deleted file mode 100644 index a94f1849c8a..00000000000 --- a/tests/lib/Security/Bruteforce/ThrottlerTest.php +++ /dev/null @@ -1,212 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace Test\Security\Bruteforce; - -use OC\Security\Bruteforce\Backend\DatabaseBackend; -use OC\Security\Bruteforce\Throttler; -use OCP\AppFramework\Utility\ITimeFactory; -use OCP\IConfig; -use OCP\IDBConnection; -use Psr\Log\LoggerInterface; -use Test\TestCase; - -/** - * Based on the unit tests from Paragonie's Airship CMS - * Ref: https://github.com/paragonie/airship/blob/7e5bad7e3c0fbbf324c11f963fd1f80e59762606/test/unit/Engine/Security/AirBrakeTest.php - * - * @package Test\Security\Bruteforce - */ -class ThrottlerTest extends TestCase { - /** @var Throttler */ - private $throttler; - /** @var IDBConnection */ - private $dbConnection; - /** @var ITimeFactory */ - private $timeFactory; - /** @var LoggerInterface */ - private $logger; - /** @var IConfig|\PHPUnit\Framework\MockObject\MockObject */ - private $config; - - protected function setUp(): void { - $this->dbConnection = $this->createMock(IDBConnection::class); - $this->timeFactory = $this->createMock(ITimeFactory::class); - $this->logger = $this->createMock(LoggerInterface::class); - $this->config = $this->createMock(IConfig::class); - - $this->throttler = new Throttler( - $this->timeFactory, - $this->logger, - $this->config, - new DatabaseBackend($this->dbConnection) - ); - parent::setUp(); - } - - public function dataIsIPWhitelisted() { - return [ - [ - '10.10.10.10', - [ - 'whitelist_0' => '10.10.10.0/24', - ], - true, - ], - [ - '10.10.10.10', - [ - 'whitelist_0' => '192.168.0.0/16', - ], - false, - ], - [ - '10.10.10.10', - [ - 'whitelist_0' => '192.168.0.0/16', - 'whitelist_1' => '10.10.10.0/24', - ], - true, - ], - [ - '10.10.10.10', - [ - 'whitelist_0' => '10.10.10.11/31', - ], - true, - ], - [ - '10.10.10.10', - [ - 'whitelist_0' => '10.10.10.9/31', - ], - false, - ], - [ - '10.10.10.10', - [ - 'whitelist_0' => '10.10.10.15/29', - ], - true, - ], - [ - 'dead:beef:cafe::1', - [ - 'whitelist_0' => '192.168.0.0/16', - 'whitelist_1' => '10.10.10.0/24', - 'whitelist_2' => 'deaf:beef:cafe:1234::/64' - ], - false, - ], - [ - 'dead:beef:cafe::1', - [ - 'whitelist_0' => '192.168.0.0/16', - 'whitelist_1' => '10.10.10.0/24', - 'whitelist_2' => 'deaf:beef::/64' - ], - false, - ], - [ - 'dead:beef:cafe::1', - [ - 'whitelist_0' => '192.168.0.0/16', - 'whitelist_1' => '10.10.10.0/24', - 'whitelist_2' => 'deaf:cafe::/8' - ], - true, - ], - [ - 'dead:beef:cafe::1111', - [ - 'whitelist_0' => 'dead:beef:cafe::1100/123', - - ], - true, - ], - [ - 'invalid', - [], - false, - ], - ]; - } - - /** - * @param string $ip - * @param string[] $whitelists - * @param bool $isWhiteListed - * @param bool $enabled - */ - private function isIpWhiteListedHelper($ip, - $whitelists, - $isWhiteListed, - $enabled) { - $this->config->method('getAppKeys') - ->with($this->equalTo('bruteForce')) - ->willReturn(array_keys($whitelists)); - $this->config - ->expects($this->once()) - ->method('getSystemValueBool') - ->with('auth.bruteforce.protection.enabled', true) - ->willReturn($enabled); - - $this->config->method('getAppValue') - ->willReturnCallback(function ($app, $key, $default) use ($whitelists) { - if ($app !== 'bruteForce') { - return $default; - } - if (isset($whitelists[$key])) { - return $whitelists[$key]; - } - return $default; - }); - - $this->assertSame( - ($enabled === false) ? true : $isWhiteListed, - self::invokePrivate($this->throttler, 'isBypassListed', [$ip]) - ); - } - - /** - * @dataProvider dataIsIPWhitelisted - * - * @param string $ip - * @param string[] $whitelists - * @param bool $isWhiteListed - */ - public function testIsIpWhiteListedWithEnabledProtection($ip, - $whitelists, - $isWhiteListed): void { - $this->isIpWhiteListedHelper( - $ip, - $whitelists, - $isWhiteListed, - true - ); - } - - /** - * @dataProvider dataIsIPWhitelisted - * - * @param string $ip - * @param string[] $whitelists - * @param bool $isWhiteListed - */ - public function testIsIpWhiteListedWithDisabledProtection($ip, - $whitelists, - $isWhiteListed): void { - $this->isIpWhiteListedHelper( - $ip, - $whitelists, - $isWhiteListed, - false - ); - } -} diff --git a/tests/lib/Security/Ip/BruteforceAllowListTest.php b/tests/lib/Security/Ip/BruteforceAllowListTest.php new file mode 100644 index 00000000000..f7ef9e3df72 --- /dev/null +++ b/tests/lib/Security/Ip/BruteforceAllowListTest.php @@ -0,0 +1,162 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\Security\Ip; + +use OC\Security\Ip\BruteforceAllowList; +use OC\Security\Ip\Factory; +use OCP\IAppConfig; +use OCP\Security\Ip\IFactory; +use PHPUnit\Framework\MockObject\MockObject; +use Test\TestCase; + +/** + * Based on the unit tests from Paragonie's Airship CMS + * Ref: https://github.com/paragonie/airship/blob/7e5bad7e3c0fbbf324c11f963fd1f80e59762606/test/unit/Engine/Security/AirBrakeTest.php + * + * @package Test\Security\Bruteforce + */ +class BruteforceAllowListTest extends TestCase { + /** @var IAppConfig|MockObject */ + private $appConfig; + /** @var IFactory|MockObject */ + private $factory; + /** @var BruteforceAllowList */ + private $allowList; + + protected function setUp(): void { + parent::setUp(); + + $this->appConfig = $this->createMock(IAppConfig::class); + $this->factory = new Factory(); + + $this->allowList = new BruteforceAllowList( + $this->appConfig, + $this->factory, + ); + } + + public function dataIsBypassListed(): array { + return [ + [ + '10.10.10.10', + [ + 'whitelist_0' => '10.10.10.0/24', + ], + true, + ], + [ + '10.10.10.10', + [ + 'whitelist_0' => '192.168.0.0/16', + ], + false, + ], + [ + '10.10.10.10', + [ + 'whitelist_0' => '192.168.0.0/16', + 'whitelist_1' => '10.10.10.0/24', + ], + true, + ], + [ + '10.10.10.10', + [ + 'whitelist_0' => '10.10.10.11/31', + ], + true, + ], + [ + '10.10.10.10', + [ + 'whitelist_0' => '10.10.10.9/31', + ], + false, + ], + [ + '10.10.10.10', + [ + 'whitelist_0' => '10.10.10.15/29', + ], + true, + ], + [ + 'dead:beef:cafe::1', + [ + 'whitelist_0' => '192.168.0.0/16', + 'whitelist_1' => '10.10.10.0/24', + 'whitelist_2' => 'deaf:beef:cafe:1234::/64' + ], + false, + ], + [ + 'dead:beef:cafe::1', + [ + 'whitelist_0' => '192.168.0.0/16', + 'whitelist_1' => '10.10.10.0/24', + 'whitelist_2' => 'deaf:beef::/64' + ], + false, + ], + [ + 'dead:beef:cafe::1', + [ + 'whitelist_0' => '192.168.0.0/16', + 'whitelist_1' => '10.10.10.0/24', + 'whitelist_2' => 'deaf:cafe::/8' + ], + true, + ], + [ + 'dead:beef:cafe::1111', + [ + 'whitelist_0' => 'dead:beef:cafe::1100/123', + ], + true, + ], + [ + 'invalid', + [], + false, + ], + ]; + } + + /** + * @dataProvider dataIsBypassListed + * + * @param string[] $allowList + */ + public function testIsBypassListed( + string $ip, + array $allowList, + bool $isAllowListed, + ): void { + $this->appConfig->method('getKeys') + ->with($this->equalTo('bruteForce')) + ->willReturn(array_keys($allowList)); + + $this->appConfig->method('getValueString') + ->willReturnCallback(function ($app, $key, $default) use ($allowList) { + if ($app !== 'bruteForce') { + return $default; + } + if (isset($allowList[$key])) { + return $allowList[$key]; + } + return $default; + }); + + $this->assertSame( + $isAllowListed, + $this->allowList->isBypassListed($ip) + ); + } +} |