diff options
21 files changed, 1026 insertions, 160 deletions
diff --git a/.drone.yml b/.drone.yml index 24e06dd1b25..61d778fc0da 100644 --- a/.drone.yml +++ b/.drone.yml @@ -277,6 +277,17 @@ pipeline: when: matrix: TESTS: integration-maintenance-mode + integration-ratelimiting: + image: nextcloudci/integration-php7.0:integration-php7.0-3 + commands: + - ./occ maintenance:install --admin-pass=admin + - ./occ config:system:set --type string --value "\\OC\\Memcache\\Redis" memcache.local + - ./occ config:system:set --type string --value "\\OC\\Memcache\\Redis" memcache.distributed + - cd build/integration + - ./run.sh features/ratelimiting.feature + when: + matrix: + TESTS: integration-ratelimiting integration-carddav: image: nextcloudci/integration-php7.0:integration-php7.0-3 commands: @@ -516,6 +527,7 @@ matrix: - TESTS: integration-capabilities_features - TESTS: integration-federation_features - TESTS: integration-maintenance-mode + - TESTS: integration-ratelimiting - TESTS: integration-auth - TESTS: integration-carddav - TESTS: integration-dav-v2 diff --git a/apps/federatedfilesharing/lib/Controller/MountPublicLinkController.php b/apps/federatedfilesharing/lib/Controller/MountPublicLinkController.php index d7e466d1a64..9f848fbbb78 100644 --- a/apps/federatedfilesharing/lib/Controller/MountPublicLinkController.php +++ b/apps/federatedfilesharing/lib/Controller/MountPublicLinkController.php @@ -120,7 +120,7 @@ class MountPublicLinkController extends Controller { * * @NoCSRFRequired * @PublicPage - * @BruteForceProtection publicLink2FederatedShare + * @BruteForceProtection(action=publicLink2FederatedShare) * * @param string $shareWith * @param string $token diff --git a/apps/files_sharing/lib/Controller/ShareController.php b/apps/files_sharing/lib/Controller/ShareController.php index 2c6e953a0f5..732a1d32ee7 100644 --- a/apps/files_sharing/lib/Controller/ShareController.php +++ b/apps/files_sharing/lib/Controller/ShareController.php @@ -160,7 +160,7 @@ class ShareController extends Controller { /** * @PublicPage * @UseSession - * @BruteForceProtection publicLinkAuth + * @BruteForceProtection(action=publicLinkAuth) * * Authenticates against password-protected shares * @param string $token diff --git a/apps/testing/appinfo/routes.php b/apps/testing/appinfo/routes.php index 13caa2289df..d45cfe00eca 100644 --- a/apps/testing/appinfo/routes.php +++ b/apps/testing/appinfo/routes.php @@ -25,12 +25,32 @@ namespace OCA\Testing\AppInfo; use OCA\Testing\Config; use OCA\Testing\Locking\Provisioning; use OCP\API; +use OCP\AppFramework\App; $config = new Config( \OC::$server->getConfig(), \OC::$server->getRequest() ); +$app = new App('testing'); +$app->registerRoutes( + $this, + [ + 'routes' => [ + [ + 'name' => 'RateLimitTest#userAndAnonProtected', + 'url' => '/userAndAnonProtected', + 'verb' => 'GET', + ], + [ + 'name' => 'RateLimitTest#onlyAnonProtected', + 'url' => '/anonProtected', + 'verb' => 'GET', + ], + ] + ] +); + API::register( 'post', '/apps/testing/api/v1/app/{appid}/{configkey}', diff --git a/apps/testing/lib/Controller/RateLimitTestController.php b/apps/testing/lib/Controller/RateLimitTestController.php new file mode 100644 index 00000000000..c43d33e5335 --- /dev/null +++ b/apps/testing/lib/Controller/RateLimitTestController.php @@ -0,0 +1,52 @@ +<?php +/** + * @copyright Copyright (c) 2017 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/>. + * + */ + +namespace OCA\Testing\Controller; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; + +class RateLimitTestController extends Controller { + /** + * @PublicPage + * @NoCSRFRequired + * + * @UserRateThrottle(limit=5, period=100) + * @AnonRateThrottle(limit=1, period=100) + * + * @return JSONResponse + */ + public function userAndAnonProtected() { + return new JSONResponse(); + } + + /** + * @PublicPage + * @NoCSRFRequired + * + * @AnonRateThrottle(limit=1, period=10) + * + * @return JSONResponse + */ + public function onlyAnonProtected() { + return new JSONResponse(); + } +} diff --git a/build/integration/features/ratelimiting.feature b/build/integration/features/ratelimiting.feature new file mode 100644 index 00000000000..bd8b2e30a73 --- /dev/null +++ b/build/integration/features/ratelimiting.feature @@ -0,0 +1,58 @@ +Feature: ratelimiting + + Background: + Given user "user0" exists + Given As an "admin" + Given app "testing" is enabled + + Scenario: Accessing a page with only an AnonRateThrottle as user + Given user "user0" exists + # First request should work + When requesting "/index.php/apps/testing/anonProtected" with "GET" using basic auth + Then the HTTP status code should be "200" + # Second one should fail + When requesting "/index.php/apps/testing/anonProtected" with "GET" using basic auth + Then the HTTP status code should be "429" + # After 11 seconds the next request should work + And Sleep for "11" seconds + When requesting "/index.php/apps/testing/anonProtected" with "GET" using basic auth + Then the HTTP status code should be "200" + + Scenario: Accessing a page with only an AnonRateThrottle as guest + Given Sleep for "11" seconds + # First request should work + When requesting "/index.php/apps/testing/anonProtected" with "GET" + Then the HTTP status code should be "200" + # Second one should fail + When requesting "/index.php/apps/testing/anonProtected" with "GET" using basic auth + Then the HTTP status code should be "429" + # After 11 seconds the next request should work + And Sleep for "11" seconds + When requesting "/index.php/apps/testing/anonProtected" with "GET" using basic auth + Then the HTTP status code should be "200" + + Scenario: Accessing a page with UserRateThrottle and AnonRateThrottle + # First request should work as guest + When requesting "/index.php/apps/testing/userAndAnonProtected" with "GET" + Then the HTTP status code should be "200" + # Second request should fail as guest + When requesting "/index.php/apps/testing/userAndAnonProtected" with "GET" + Then the HTTP status code should be "429" + # First request should work as user + When requesting "/index.php/apps/testing/userAndAnonProtected" with "GET" using basic auth + Then the HTTP status code should be "200" + # Second request should work as user + When requesting "/index.php/apps/testing/userAndAnonProtected" with "GET" using basic auth + Then the HTTP status code should be "200" + # Third request should work as user + When requesting "/index.php/apps/testing/userAndAnonProtected" with "GET" using basic auth + Then the HTTP status code should be "200" + # Fourth request should work as user + When requesting "/index.php/apps/testing/userAndAnonProtected" with "GET" using basic auth + Then the HTTP status code should be "200" + # Fifth request should work as user + When requesting "/index.php/apps/testing/userAndAnonProtected" with "GET" using basic auth + Then the HTTP status code should be "200" + # Sixth request should fail as user + When requesting "/index.php/apps/testing/userAndAnonProtected" with "GET" + Then the HTTP status code should be "429" diff --git a/core/Controller/LostController.php b/core/Controller/LostController.php index 639dd9da574..a4768cbeafc 100644 --- a/core/Controller/LostController.php +++ b/core/Controller/LostController.php @@ -204,7 +204,7 @@ class LostController extends Controller { /** * @PublicPage - * @BruteForceProtection passwordResetEmail + * @BruteForceProtection(action=passwordResetEmail) * * @param string $user * @return array diff --git a/lib/private/AppFramework/DependencyInjection/DIContainer.php b/lib/private/AppFramework/DependencyInjection/DIContainer.php index 4fb13b09ae0..a414772c4d6 100644 --- a/lib/private/AppFramework/DependencyInjection/DIContainer.php +++ b/lib/private/AppFramework/DependencyInjection/DIContainer.php @@ -53,11 +53,13 @@ use OCP\AppFramework\QueryException; use OCP\Files\Folder; use OCP\Files\IAppData; use OCP\IL10N; +use OCP\IMemcache; use OCP\IRequest; use OCP\IServerContainer; use OCP\IUserSession; use OCP\RichObjectStrings\IValidator; use OCP\Util; +use SearchDAV\XML\Limit; class DIContainer extends SimpleContainer implements IAppContainer { @@ -162,6 +164,22 @@ class DIContainer extends SimpleContainer implements IAppContainer { return $c->query(Validator::class); }); + $this->registerService(OC\Security\RateLimiting\Limiter::class, function($c) { + return new OC\Security\RateLimiting\Limiter( + $this->getServer()->getUserSession(), + $this->getServer()->getRequest(), + new OC\AppFramework\Utility\TimeFactory(), + $c->query(OC\Security\RateLimiting\Backend\IBackend::class) + ); + }); + + $this->registerService(OC\Security\RateLimiting\Backend\IBackend::class, function($c) { + return new OC\Security\RateLimiting\Backend\MemoryCache( + $this->getServer()->getMemCacheFactory(), + new OC\AppFramework\Utility\TimeFactory() + ); + }); + $this->registerService(\OC\Security\IdentityProof\Manager::class, function ($c) { return new \OC\Security\IdentityProof\Manager( $this->getServer()->getAppDataDir('identityproof'), @@ -169,7 +187,6 @@ class DIContainer extends SimpleContainer implements IAppContainer { ); }); - /** * App Framework APIs */ @@ -220,12 +237,13 @@ class DIContainer extends SimpleContainer implements IAppContainer { $server->getLogger(), $server->getSession(), $c['AppName'], - $app->isLoggedIn(), + $server->getUserSession(), $app->isAdminUser(), $server->getContentSecurityPolicyManager(), $server->getCsrfTokenManager(), $server->getContentSecurityPolicyNonceManager(), - $server->getBruteForceThrottler() + $server->getBruteForceThrottler(), + $c->query(OC\Security\RateLimiting\Limiter::class) ); }); diff --git a/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php b/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php index edba6a3e759..d8bbe84c86e 100644 --- a/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php +++ b/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php @@ -26,7 +26,6 @@ * */ - namespace OC\AppFramework\Middleware\Security; use OC\AppFramework\Middleware\Security\Exceptions\AppNotEnabledException; @@ -40,6 +39,7 @@ use OC\Security\Bruteforce\Throttler; use OC\Security\CSP\ContentSecurityPolicyManager; use OC\Security\CSP\ContentSecurityPolicyNonceManager; use OC\Security\CSRF\CsrfTokenManager; +use OC\Security\RateLimiting\Limiter; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\EmptyContentSecurityPolicy; use OCP\AppFramework\Http\RedirectResponse; @@ -54,6 +54,7 @@ use OCP\IURLGenerator; use OCP\IRequest; use OCP\ILogger; use OCP\AppFramework\Controller; +use OCP\IUserSession; use OCP\Util; use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; @@ -78,8 +79,8 @@ class SecurityMiddleware extends Middleware { private $logger; /** @var ISession */ private $session; - /** @var bool */ - private $isLoggedIn; + /** @var IUserSession */ + private $userSession; /** @var bool */ private $isAdminUser; /** @var ContentSecurityPolicyManager */ @@ -90,6 +91,8 @@ class SecurityMiddleware extends Middleware { private $cspNonceManager; /** @var Throttler */ private $throttler; + /** @var Limiter */ + private $limiter; /** * @param IRequest $request @@ -99,12 +102,13 @@ class SecurityMiddleware extends Middleware { * @param ILogger $logger * @param ISession $session * @param string $appName - * @param bool $isLoggedIn + * @param IUserSession $userSession * @param bool $isAdminUser * @param ContentSecurityPolicyManager $contentSecurityPolicyManager * @param CSRFTokenManager $csrfTokenManager * @param ContentSecurityPolicyNonceManager $cspNonceManager * @param Throttler $throttler + * @param Limiter $limiter */ public function __construct(IRequest $request, ControllerMethodReflector $reflector, @@ -113,12 +117,13 @@ class SecurityMiddleware extends Middleware { ILogger $logger, ISession $session, $appName, - $isLoggedIn, + IUserSession $userSession, $isAdminUser, ContentSecurityPolicyManager $contentSecurityPolicyManager, CsrfTokenManager $csrfTokenManager, ContentSecurityPolicyNonceManager $cspNonceManager, - Throttler $throttler) { + Throttler $throttler, + Limiter $limiter) { $this->navigationManager = $navigationManager; $this->request = $request; $this->reflector = $reflector; @@ -126,15 +131,15 @@ class SecurityMiddleware extends Middleware { $this->urlGenerator = $urlGenerator; $this->logger = $logger; $this->session = $session; - $this->isLoggedIn = $isLoggedIn; + $this->userSession = $userSession; $this->isAdminUser = $isAdminUser; $this->contentSecurityPolicyManager = $contentSecurityPolicyManager; $this->csrfTokenManager = $csrfTokenManager; $this->cspNonceManager = $cspNonceManager; $this->throttler = $throttler; + $this->limiter = $limiter; } - /** * This runs all the security checks before a method call. The * security checks are determined by inspecting the controller method @@ -152,7 +157,7 @@ class SecurityMiddleware extends Middleware { // security checks $isPublicPage = $this->reflector->hasAnnotation('PublicPage'); if(!$isPublicPage) { - if(!$this->isLoggedIn) { + if(!$this->userSession->isLoggedIn()) { throw new NotLoggedInException(); } @@ -191,8 +196,29 @@ class SecurityMiddleware extends Middleware { } } + $anonLimit = $this->reflector->getAnnotationParameter('AnonRateThrottle', 'limit'); + $anonPeriod = $this->reflector->getAnnotationParameter('AnonRateThrottle', 'period'); + $userLimit = $this->reflector->getAnnotationParameter('UserRateThrottle', 'limit'); + $userPeriod = $this->reflector->getAnnotationParameter('UserRateThrottle', 'period'); + $rateLimitIdentifier = get_class($controller) . '::' . $methodName; + if($userLimit !== '' && $userPeriod !== '' && $this->userSession->isLoggedIn()) { + $this->limiter->registerUserRequest( + $rateLimitIdentifier, + $userLimit, + $userPeriod, + $this->userSession->getUser() + ); + } elseif ($anonLimit !== '' && $anonPeriod !== '') { + $this->limiter->registerAnonRequest( + $rateLimitIdentifier, + $anonLimit, + $anonPeriod, + $this->request->getRemoteAddress() + ); + } + if($this->reflector->hasAnnotation('BruteForceProtection')) { - $action = $this->reflector->getAnnotationParameter('BruteForceProtection'); + $action = $this->reflector->getAnnotationParameter('BruteForceProtection', 'action'); $this->throttler->sleepDelay($this->request->getRemoteAddress(), $action); $this->throttler->registerAttempt($action, $this->request->getRemoteAddress()); } @@ -206,7 +232,6 @@ class SecurityMiddleware extends Middleware { if(\OC_App::getAppPath($this->appName) !== false && !\OC_App::isEnabled($this->appName)) { throw new AppNotEnabledException(); } - } /** diff --git a/lib/private/AppFramework/Utility/ControllerMethodReflector.php b/lib/private/AppFramework/Utility/ControllerMethodReflector.php index 034fc3a1759..19eafdb25ac 100644 --- a/lib/private/AppFramework/Utility/ControllerMethodReflector.php +++ b/lib/private/AppFramework/Utility/ControllerMethodReflector.php @@ -24,27 +24,17 @@ * */ - namespace OC\AppFramework\Utility; use \OCP\AppFramework\Utility\IControllerMethodReflector; - /** * Reads and parses annotations from doc comments */ -class ControllerMethodReflector implements IControllerMethodReflector{ - - private $annotations; - private $types; - private $parameters; - - public function __construct() { - $this->types = array(); - $this->parameters = array(); - $this->annotations = array(); - } - +class ControllerMethodReflector implements IControllerMethodReflector { + public $annotations = []; + private $types = []; + private $parameters = []; /** * @param object $object an object or classname @@ -55,9 +45,21 @@ class ControllerMethodReflector implements IControllerMethodReflector{ $docs = $reflection->getDocComment(); // extract everything prefixed by @ and first letter uppercase - preg_match_all('/^\h+\*\h+@(?P<annotation>[A-Z]\w+)(\h+(?P<parameter>\w+))?$/m', $docs, $matches); + preg_match_all('/^\h+\*\h+@(?P<annotation>[A-Z]\w+)((?P<parameter>.*))?$/m', $docs, $matches); foreach($matches['annotation'] as $key => $annontation) { - $this->annotations[$annontation] = $matches['parameter'][$key]; + $annotationValue = $matches['parameter'][$key]; + if($annotationValue[0] === '(' && $annotationValue[strlen($annotationValue) - 1] === ')') { + $cutString = substr($annotationValue, 1, -1); + $cutString = str_replace(' ', '', $cutString); + $splittedArray = explode(',', $cutString); + foreach($splittedArray as $annotationValues) { + list($key, $value) = explode('=', $annotationValues); + $this->annotations[$annontation][$key] = $value; + } + continue; + } + + $this->annotations[$annontation] = [$annotationValue]; } // extract type parameter information @@ -83,7 +85,6 @@ class ControllerMethodReflector implements IControllerMethodReflector{ } } - /** * Inspects the PHPDoc parameters for types * @param string $parameter the parameter whose type comments should be @@ -99,7 +100,6 @@ class ControllerMethodReflector implements IControllerMethodReflector{ } } - /** * @return array the arguments of the method with key => default value */ @@ -107,30 +107,27 @@ class ControllerMethodReflector implements IControllerMethodReflector{ return $this->parameters; } - /** * Check if a method contains an annotation * @param string $name the name of the annotation * @return bool true if the annotation is found */ - public function hasAnnotation($name){ + public function hasAnnotation($name) { return array_key_exists($name, $this->annotations); } - /** - * Get optional annotation parameter + * Get optional annotation parameter by key + * * @param string $name the name of the annotation + * @param string $key the string of the annotation * @return string */ - public function getAnnotationParameter($name){ - $parameter = ''; - if($this->hasAnnotation($name)) { - $parameter = $this->annotations[$name]; + public function getAnnotationParameter($name, $key) { + if(isset($this->annotations[$name][$key])) { + return $this->annotations[$name][$key]; } - return $parameter; + return ''; } - - } diff --git a/lib/private/Security/Bruteforce/Throttler.php b/lib/private/Security/Bruteforce/Throttler.php index 73a27b677b0..b2524b63c63 100644 --- a/lib/private/Security/Bruteforce/Throttler.php +++ b/lib/private/Security/Bruteforce/Throttler.php @@ -23,6 +23,7 @@ namespace OC\Security\Bruteforce; +use OC\Security\Normalizer\IpAddress; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; use OCP\IDBConnection; @@ -83,67 +84,6 @@ class Throttler { } /** - * Return the given subnet for an IPv4 address and mask bits - * - * @param string $ip - * @param int $maskBits - * @return string - */ - private function getIPv4Subnet($ip, - $maskBits = 32) { - $binary = \inet_pton($ip); - for ($i = 32; $i > $maskBits; $i -= 8) { - $j = \intdiv($i, 8) - 1; - $k = (int) \min(8, $i - $maskBits); - $mask = (0xff - ((pow(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 - */ - private function getIPv6Subnet($ip, $maskBits = 48) { - $binary = \inet_pton($ip); - for ($i = 128; $i > $maskBits; $i -= 8) { - $j = \intdiv($i, 8) - 1; - $k = (int) \min(8, $i - $maskBits); - $mask = (0xff - ((pow(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 IP and the configured mask bits - * - * Determine if the IP is an IPv4 or IPv6 address, then pass to the correct - * method for handling that specific type. - * - * @param string $ip - * @return string - */ - private function getSubnet($ip) { - if (\preg_match('/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/', $ip)) { - return $this->getIPv4Subnet( - $ip, - 32 - ); - } - return $this->getIPv6Subnet( - $ip, - 128 - ); - } - - /** * Register a failed attempt to bruteforce a security control * * @param string $action @@ -158,11 +98,12 @@ class Throttler { return; } + $ipAddress = new IpAddress($ip); $values = [ 'action' => $action, 'occurred' => $this->timeFactory->getTime(), - 'ip' => $ip, - 'subnet' => $this->getSubnet($ip), + 'ip' => (string)$ipAddress, + 'subnet' => $ipAddress->getSubnet(), 'metadata' => json_encode($metadata), ]; @@ -254,7 +195,8 @@ class Throttler { * @return int */ public function getDelay($ip, $action = '') { - if ($this->isIPWhitelisted($ip)) { + $ipAddress = new IpAddress($ip); + if ($this->isIPWhitelisted((string)$ipAddress)) { return 0; } @@ -266,7 +208,7 @@ class Throttler { $qb->select('*') ->from('bruteforce_attempts') ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime))) - ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($this->getSubnet($ip)))); + ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet()))); if ($action !== '') { $qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action))); diff --git a/lib/private/Security/Normalizer/IpAddress.php b/lib/private/Security/Normalizer/IpAddress.php new file mode 100644 index 00000000000..c44a5556678 --- /dev/null +++ b/lib/private/Security/Normalizer/IpAddress.php @@ -0,0 +1,106 @@ +<?php +/** + * @copyright Copyright (c) 2017 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/>. + * + */ + +namespace OC\Security\Normalizer; + +/** + * Class IpAddress is used for normalizing IPv4 and IPv6 addresses in security + * relevant contexts in Nextcloud. + * + * @package OC\Security\Normalizer + */ +class IpAddress { + /** @var string */ + private $ip; + + /** + * @param string $ip IP to normalized + */ + public function __construct($ip) { + $this->ip = $ip; + } + + /** + * Return the given subnet for an IPv4 address and mask bits + * + * @param string $ip + * @param int $maskBits + * @return string + */ + private function getIPv4Subnet($ip, + $maskBits = 32) { + $binary = \inet_pton($ip); + for ($i = 32; $i > $maskBits; $i -= 8) { + $j = \intdiv($i, 8) - 1; + $k = (int) \min(8, $i - $maskBits); + $mask = (0xff - ((pow(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 + */ + private function getIPv6Subnet($ip, $maskBits = 48) { + $binary = \inet_pton($ip); + for ($i = 128; $i > $maskBits; $i -= 8) { + $j = \intdiv($i, 8) - 1; + $k = (int) \min(8, $i - $maskBits); + $mask = (0xff - ((pow(2, $k)) - 1)); + $int = \unpack('C', $binary[$j]); + $binary[$j] = \pack('C', $int[1] & $mask); + } + return \inet_ntop($binary).'/'.$maskBits; + } + + /** + * Gets either the /32 (IPv4) or the /128 (IPv6) subnet of an IP address + * + * @return string + */ + public function getSubnet() { + 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 + ); + } + return $this->getIPv6Subnet( + $this->ip, + 128 + ); + } + + /** + * Returns the specified IP address + * + * @return string + */ + public function __toString() { + return $this->ip; + } +} diff --git a/lib/private/Security/RateLimiting/Backend/IBackend.php b/lib/private/Security/RateLimiting/Backend/IBackend.php new file mode 100644 index 00000000000..092c0e7bb8a --- /dev/null +++ b/lib/private/Security/RateLimiting/Backend/IBackend.php @@ -0,0 +1,50 @@ +<?php +/** + * @copyright Copyright (c) 2017 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/>. + * + */ + +namespace OC\Security\RateLimiting\Backend; + +/** + * Interface IBackend defines a storage backend for the rate limiting data. It + * should be noted that writing and reading rate limiting data is an expensive + * operation and one should thus make sure to only use sufficient fast backends. + * + * @package OC\Security\RateLimiting\Backend + */ +interface IBackend { + /** + * Gets the amount of attempts within the last specified seconds + * + * @param string $methodIdentifier + * @param string $userIdentifier + * @param int $seconds + * @return int + */ + public function getAttempts($methodIdentifier, $userIdentifier, $seconds); + + /** + * Registers an attempt + * + * @param string $methodIdentifier + * @param string $userIdentifier + * @param int $timestamp + */ + public function registerAttempt($methodIdentifier, $userIdentifier, $timestamp); +} diff --git a/lib/private/Security/RateLimiting/Backend/MemoryCache.php b/lib/private/Security/RateLimiting/Backend/MemoryCache.php new file mode 100644 index 00000000000..a0c53335bcf --- /dev/null +++ b/lib/private/Security/RateLimiting/Backend/MemoryCache.php @@ -0,0 +1,100 @@ +<?php +/** + * @copyright Copyright (c) 2017 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/>. + * + */ + +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->create(__CLASS__); + $this->timeFactory = $timeFactory; + } + + /** + * @param string $methodIdentifier + * @param string $userIdentifier + * @return string + */ + private function hash($methodIdentifier, $userIdentifier) { + return hash('sha512', $methodIdentifier . $userIdentifier); + } + + /** + * @param string $identifier + * @return array + */ + private function getExistingAttempts($identifier) { + $cachedAttempts = json_decode($this->cache->get($identifier), true); + if(is_array($cachedAttempts)) { + return $cachedAttempts; + } + + return []; + } + + /** + * {@inheritDoc} + */ + public function getAttempts($methodIdentifier, $userIdentifier, $seconds) { + $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($methodIdentifier, $userIdentifier, $timestamp) { + $identifier = $this->hash($methodIdentifier, $userIdentifier); + $existingAttempts = $this->getExistingAttempts($identifier); + $existingAttempts[] = (string)$timestamp; + $this->cache->set($identifier, json_encode($existingAttempts)); + } +} diff --git a/lib/private/Security/RateLimiting/Exception/RateLimitExceededException.php b/lib/private/Security/RateLimiting/Exception/RateLimitExceededException.php new file mode 100644 index 00000000000..34cbec31c73 --- /dev/null +++ b/lib/private/Security/RateLimiting/Exception/RateLimitExceededException.php @@ -0,0 +1,31 @@ +<?php +/** + * @copyright Copyright (c) 2017 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/>. + * + */ + +namespace OC\Security\RateLimiting\Exception; + +use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; +use OCP\AppFramework\Http; + +class RateLimitExceededException extends SecurityException { + public function __construct() { + parent::__construct('Rate limit exceeded', Http::STATUS_TOO_MANY_REQUESTS); + } +} diff --git a/lib/private/Security/RateLimiting/Limiter.php b/lib/private/Security/RateLimiting/Limiter.php new file mode 100644 index 00000000000..5c084eb934b --- /dev/null +++ b/lib/private/Security/RateLimiting/Limiter.php @@ -0,0 +1,106 @@ +<?php +/** + * @copyright Copyright (c) 2017 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/>. + * + */ + +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\IRequest; +use OCP\IUser; +use OCP\IUserSession; + +class Limiter { + /** @var IBackend */ + private $backend; + /** @var ITimeFactory */ + private $timeFactory; + + /** + * @param IUserSession $userSession + * @param IRequest $request + * @param ITimeFactory $timeFactory + * @param IBackend $backend + */ + public function __construct(IUserSession $userSession, + IRequest $request, + ITimeFactory $timeFactory, + IBackend $backend) { + $this->backend = $backend; + $this->timeFactory = $timeFactory; + } + + /** + * @param string $methodIdentifier + * @param string $userIdentifier + * @param int $period + * @param int $limit + * @throws RateLimitExceededException + */ + private function register($methodIdentifier, + $userIdentifier, + $period, + $limit) { + $existingAttempts = $this->backend->getAttempts($methodIdentifier, $userIdentifier, (int)$period); + if ($existingAttempts >= (int)$limit) { + throw new RateLimitExceededException(); + } + + $this->backend->registerAttempt($methodIdentifier, $userIdentifier, $this->timeFactory->getTime()); + } + + /** + * Registers attempt for an anonymous request + * + * @param string $identifier + * @param int $anonLimit + * @param int $anonPeriod + * @param string $ip + * @throws RateLimitExceededException + */ + public function registerAnonRequest($identifier, + $anonLimit, + $anonPeriod, + $ip) { + $ipSubnet = (new IpAddress($ip))->getSubnet(); + + $anonHashIdentifier = hash('sha512', 'anon::' . $identifier . $ipSubnet); + $this->register($identifier, $anonHashIdentifier, $anonPeriod, $anonLimit); + } + + /** + * Registers attempt for an authenticated request + * + * @param string $identifier + * @param int $userLimit + * @param int $userPeriod + * @param IUser $user + * @throws RateLimitExceededException + */ + public function registerUserRequest($identifier, + $userLimit, + $userPeriod, + IUser $user) { + $userHashIdentifier = hash('sha512', 'user::' . $identifier . $user->getUID()); + $this->register($identifier, $userHashIdentifier, $userPeriod, $userLimit); + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/SecurityMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/SecurityMiddlewareTest.php index 164ea48de70..2b99c3347f5 100644 --- a/tests/lib/AppFramework/Middleware/Security/SecurityMiddlewareTest.php +++ b/tests/lib/AppFramework/Middleware/Security/SecurityMiddlewareTest.php @@ -40,6 +40,7 @@ use OC\Security\CSP\ContentSecurityPolicyManager; use OC\Security\CSP\ContentSecurityPolicyNonceManager; use OC\Security\CSRF\CsrfToken; use OC\Security\CSRF\CsrfTokenManager; +use OC\Security\RateLimiting\Limiter; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\EmptyContentSecurityPolicy; use OCP\AppFramework\Http\RedirectResponse; @@ -52,6 +53,7 @@ use OCP\INavigationManager; use OCP\IRequest; use OCP\ISession; use OCP\IURLGenerator; +use OCP\IUserSession; use OCP\Security\ISecureRandom; @@ -83,6 +85,10 @@ class SecurityMiddlewareTest extends \Test\TestCase { private $csrfTokenManager; /** @var ContentSecurityPolicyNonceManager|\PHPUnit_Framework_MockObject_MockObject */ private $cspNonceManager; + /** @var IUserSession|\PHPUnit_Framework_MockObject_MockObject */ + private $userSession; + /** @var Limiter|\PHPUnit_Framework_MockObject_MockObject */ + private $limiter; /** @var Throttler|\PHPUnit_Framework_MockObject_MockObject */ private $bruteForceThrottler; @@ -93,6 +99,8 @@ class SecurityMiddlewareTest extends \Test\TestCase { $this->reader = new ControllerMethodReflector(); $this->logger = $this->createMock(ILogger::class); $this->navigationManager = $this->createMock(INavigationManager::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->limiter = $this->createMock(Limiter::class); $this->urlGenerator = $this->createMock(IURLGenerator::class); $this->session = $this->createMock(ISession::class); $this->request = $this->createMock(IRequest::class); @@ -111,6 +119,11 @@ class SecurityMiddlewareTest extends \Test\TestCase { * @return SecurityMiddleware */ private function getMiddleware($isLoggedIn, $isAdminUser) { + $this->userSession + ->expects($this->any()) + ->method('isLoggedIn') + ->willReturn($isLoggedIn); + return new SecurityMiddleware( $this->request, $this->reader, @@ -119,12 +132,13 @@ class SecurityMiddlewareTest extends \Test\TestCase { $this->logger, $this->session, 'files', - $isLoggedIn, + $this->userSession, $isAdminUser, $this->contentSecurityPolicyManager, $this->csrfTokenManager, $this->cspNonceManager, - $this->bruteForceThrottler + $this->bruteForceThrottler, + $this->limiter ); } @@ -673,14 +687,36 @@ class SecurityMiddlewareTest extends \Test\TestCase { $this->logger, $this->session, 'files', - false, + $this->userSession, false, $this->contentSecurityPolicyManager, $this->csrfTokenManager, $this->cspNonceManager, - $this->bruteForceThrottler + $this->bruteForceThrottler, + $this->limiter ); + $reader + ->expects($this->at(0)) + ->method('getAnnotationParameter') + ->with('AnonRateThrottle', 'limit') + ->willReturn(''); + $reader + ->expects($this->at(1)) + ->method('getAnnotationParameter') + ->with('AnonRateThrottle', 'period') + ->willReturn(''); + $reader + ->expects($this->at(2)) + ->method('getAnnotationParameter') + ->with('UserRateThrottle', 'limit') + ->willReturn(''); + $reader + ->expects($this->at(3)) + ->method('getAnnotationParameter') + ->with('UserRateThrottle', 'period') + ->willReturn(''); + $reader->expects($this->any())->method('hasAnnotation') ->willReturnCallback( function($annotation) use ($bruteForceProtectionEnabled) { diff --git a/tests/lib/Security/Bruteforce/ThrottlerTest.php b/tests/lib/Security/Bruteforce/ThrottlerTest.php index 02d5b701679..9679d0c1759 100644 --- a/tests/lib/Security/Bruteforce/ThrottlerTest.php +++ b/tests/lib/Security/Bruteforce/ThrottlerTest.php @@ -76,51 +76,6 @@ class ThrottlerTest extends TestCase { $this->assertLessThan(2, $cutoff->s); } - public function testSubnet() { - // IPv4 - $this->assertSame( - '64.233.191.254/32', - $this->invokePrivate($this->throttler, 'getIPv4Subnet', ['64.233.191.254', 32]) - ); - $this->assertSame( - '64.233.191.252/30', - $this->invokePrivate($this->throttler, 'getIPv4Subnet', ['64.233.191.254', 30]) - ); - $this->assertSame( - '64.233.191.240/28', - $this->invokePrivate($this->throttler, 'getIPv4Subnet', ['64.233.191.254', 28]) - ); - $this->assertSame( - '64.233.191.0/24', - $this->invokePrivate($this->throttler, 'getIPv4Subnet', ['64.233.191.254', 24]) - ); - $this->assertSame( - '64.233.188.0/22', - $this->invokePrivate($this->throttler, 'getIPv4Subnet', ['64.233.191.254', 22]) - ); - // IPv6 - $this->assertSame( - '2001:db8:85a3::8a2e:370:7334/127', - $this->invokePrivate($this->throttler, 'getIPv6Subnet', ['2001:0db8:85a3:0000:0000:8a2e:0370:7334', 127]) - ); - $this->assertSame( - '2001:db8:85a3::8a2e:370:7300/120', - $this->invokePrivate($this->throttler, 'getIPv6Subnet', ['2001:0db8:85a3:0000:0000:8a2e:0370:7300', 120]) - ); - $this->assertSame( - '2001:db8:85a3::/64', - $this->invokePrivate($this->throttler, 'getIPv6Subnet', ['2001:0db8:85a3:0000:0000:8a2e:0370:7334', 64]) - ); - $this->assertSame( - '2001:db8:85a3::/48', - $this->invokePrivate($this->throttler, 'getIPv6Subnet', ['2001:0db8:85a3:0000:0000:8a2e:0370:7334', 48]) - ); - $this->assertSame( - '2001:db8:8500::/40', - $this->invokePrivate($this->throttler, 'getIPv6Subnet', ['2001:0db8:85a3:0000:0000:8a2e:0370:7334', 40]) - ); - } - public function dataIsIPWhitelisted() { return [ [ diff --git a/tests/lib/Security/Normalizer/IpAddressTest.php b/tests/lib/Security/Normalizer/IpAddressTest.php new file mode 100644 index 00000000000..36a48f601d3 --- /dev/null +++ b/tests/lib/Security/Normalizer/IpAddressTest.php @@ -0,0 +1,59 @@ +<?php +/** + * @copyright Copyright (c) 2017 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/>. + * + */ + +namespace Test\Security\Normalizer; + +use OC\Security\Normalizer\IpAddress; +use Test\TestCase; + +class IpAddressTest extends TestCase { + + public function subnetDataProvider() { + return [ + [ + '64.233.191.254', + '64.233.191.254/32', + ], + [ + '192.168.0.123', + '192.168.0.123/32', + ], + [ + '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + '2001:db8:85a3::8a2e:370:7334/128', + ], + ]; + } + + /** + * @dataProvider subnetDataProvider + * + * @param string $input + * @param string $expected + */ + public function testGetSubnet($input, $expected) { + $this->assertSame($expected, (new IpAddress($input))->getSubnet()); + } + + public function testToString() { + $this->assertSame('127.0.0.1', (string)(new IpAddress('127.0.0.1'))); + } +} diff --git a/tests/lib/Security/RateLimiting/Backend/MemoryCacheTest.php b/tests/lib/Security/RateLimiting/Backend/MemoryCacheTest.php new file mode 100644 index 00000000000..f00d734661d --- /dev/null +++ b/tests/lib/Security/RateLimiting/Backend/MemoryCacheTest.php @@ -0,0 +1,138 @@ +<?php +/** + * @copyright Copyright (c) 2017 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/>. + * + */ + +namespace Test\Security\RateLimiting\Backend; + +use OC\Security\RateLimiting\Backend\MemoryCache; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\ICache; +use OCP\ICacheFactory; +use Test\TestCase; + +class MemoryCacheTest extends TestCase { + /** @var ICacheFactory|\PHPUnit_Framework_MockObject_MockObject */ + private $cacheFactory; + /** @var ITimeFactory|\PHPUnit_Framework_MockObject_MockObject */ + private $timeFactory; + /** @var ICache|\PHPUnit_Framework_MockObject_MockObject */ + private $cache; + /** @var MemoryCache */ + private $memoryCache; + + public function setUp() { + parent::setUp(); + + $this->cacheFactory = $this->createMock(ICacheFactory::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->cache = $this->createMock(ICache::class); + + $this->cacheFactory + ->expects($this->once()) + ->method('create') + ->with('OC\Security\RateLimiting\Backend\MemoryCache') + ->willReturn($this->cache); + + $this->memoryCache = new MemoryCache( + $this->cacheFactory, + $this->timeFactory + ); + } + + public function testGetAttemptsWithNoAttemptsBefore() { + $this->cache + ->expects($this->once()) + ->method('get') + ->with('eea460b8d756885099c7f0a4c083bf6a745069ee4a301984e726df58fd4510bffa2dac4b7fd5d835726a6753ffa8343ba31c7e902bbef78fc68c2e743667cb4b') + ->willReturn(false); + + $this->assertSame(0, $this->memoryCache->getAttempts('Method', 'User', 123)); + } + + public function testGetAttempts() { + $this->timeFactory + ->expects($this->once()) + ->method('getTime') + ->willReturn(210); + $this->cache + ->expects($this->once()) + ->method('get') + ->with('eea460b8d756885099c7f0a4c083bf6a745069ee4a301984e726df58fd4510bffa2dac4b7fd5d835726a6753ffa8343ba31c7e902bbef78fc68c2e743667cb4b') + ->willReturn(json_encode([ + '1', + '2', + '87', + '123', + '123', + '124', + ])); + + $this->assertSame(3, $this->memoryCache->getAttempts('Method', 'User', 123)); + } + + public function testRegisterAttemptWithNoAttemptsBefore() { + $this->cache + ->expects($this->once()) + ->method('get') + ->with('eea460b8d756885099c7f0a4c083bf6a745069ee4a301984e726df58fd4510bffa2dac4b7fd5d835726a6753ffa8343ba31c7e902bbef78fc68c2e743667cb4b') + ->willReturn(false); + $this->cache + ->expects($this->once()) + ->method('set') + ->with( + 'eea460b8d756885099c7f0a4c083bf6a745069ee4a301984e726df58fd4510bffa2dac4b7fd5d835726a6753ffa8343ba31c7e902bbef78fc68c2e743667cb4b', + json_encode(['123']) + ); + + $this->memoryCache->registerAttempt('Method', 'User', 123); + } + + public function testRegisterAttempts() { + $this->cache + ->expects($this->once()) + ->method('get') + ->with('eea460b8d756885099c7f0a4c083bf6a745069ee4a301984e726df58fd4510bffa2dac4b7fd5d835726a6753ffa8343ba31c7e902bbef78fc68c2e743667cb4b') + ->willReturn(json_encode([ + '1', + '2', + '87', + '123', + '123', + '124', + ])); + $this->cache + ->expects($this->once()) + ->method('set') + ->with( + 'eea460b8d756885099c7f0a4c083bf6a745069ee4a301984e726df58fd4510bffa2dac4b7fd5d835726a6753ffa8343ba31c7e902bbef78fc68c2e743667cb4b', + json_encode([ + '1', + '2', + '87', + '123', + '123', + '124', + '129', + ]) + ); + + $this->memoryCache->registerAttempt('Method', 'User', 129); + } +} diff --git a/tests/lib/Security/RateLimiting/LimiterTest.php b/tests/lib/Security/RateLimiting/LimiterTest.php new file mode 100644 index 00000000000..80b63ebb391 --- /dev/null +++ b/tests/lib/Security/RateLimiting/LimiterTest.php @@ -0,0 +1,161 @@ +<?php +/** + * @copyright Copyright (c) 2017 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/>. + * + */ + +namespace Test\Security\RateLimiting; + +use OC\Security\RateLimiting\Backend\IBackend; +use OC\Security\RateLimiting\Limiter; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\ICacheFactory; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use Test\TestCase; + +class LimiterTest extends TestCase { + /** @var IUserSession|\PHPUnit_Framework_MockObject_MockObject */ + private $userSession; + /** @var IRequest|\PHPUnit_Framework_MockObject_MockObject */ + private $request; + /** @var ITimeFactory|\PHPUnit_Framework_MockObject_MockObject */ + private $timeFactory; + /** @var IBackend|\PHPUnit_Framework_MockObject_MockObject */ + private $backend; + /** @var Limiter */ + private $limiter; + + public function setUp() { + parent::setUp(); + + $this->userSession = $this->createMock(IUserSession::class); + $this->request = $this->createMock(IRequest::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->backend = $this->createMock(IBackend::class); + + $this->limiter = new Limiter( + $this->userSession, + $this->request, + $this->timeFactory, + $this->backend + ); + } + + /** + * @expectedException \OC\Security\RateLimiting\Exception\RateLimitExceededException + * @expectedExceptionMessage Rate limit exceeded + */ + public function testRegisterAnonRequestExceeded() { + $this->backend + ->expects($this->once()) + ->method('getAttempts') + ->with( + 'MyIdentifier', + '4664f0d9c88dcb7552be47b37bb52ce35977b2e60e1ac13757cf625f31f87050a41f3da064887fa87d49fd042e4c8eb20de8f10464877d3959677ab011b73a47', + 100 + ) + ->willReturn(101); + + $this->limiter->registerAnonRequest('MyIdentifier', 100, 100, '127.0.0.1'); + } + + public function testRegisterAnonRequestSuccess() { + $this->timeFactory + ->expects($this->once()) + ->method('getTime') + ->willReturn(2000); + $this->backend + ->expects($this->once()) + ->method('getAttempts') + ->with( + 'MyIdentifier', + '4664f0d9c88dcb7552be47b37bb52ce35977b2e60e1ac13757cf625f31f87050a41f3da064887fa87d49fd042e4c8eb20de8f10464877d3959677ab011b73a47', + 100 + ) + ->willReturn(99); + $this->backend + ->expects($this->once()) + ->method('registerAttempt') + ->with( + 'MyIdentifier', + '4664f0d9c88dcb7552be47b37bb52ce35977b2e60e1ac13757cf625f31f87050a41f3da064887fa87d49fd042e4c8eb20de8f10464877d3959677ab011b73a47', + 2000 + ); + + $this->limiter->registerAnonRequest('MyIdentifier', 100, 100, '127.0.0.1'); + } + + /** + * @expectedException \OC\Security\RateLimiting\Exception\RateLimitExceededException + * @expectedExceptionMessage Rate limit exceeded + */ + public function testRegisterUserRequestExceeded() { + /** @var IUser|\PHPUnit_Framework_MockObject_MockObject $user */ + $user = $this->createMock(IUser::class); + $user + ->expects($this->once()) + ->method('getUID') + ->willReturn('MyUid'); + $this->backend + ->expects($this->once()) + ->method('getAttempts') + ->with( + 'MyIdentifier', + 'ddb2ec50fa973fd49ecf3d816f677c8095143e944ad10485f30fb3dac85c13a346dace4dae2d0a15af91867320957bfd38a43d9eefbb74fe6919e15119b6d805', + 100 + ) + ->willReturn(101); + + $this->limiter->registerUserRequest('MyIdentifier', 100, 100, $user); + } + + public function testRegisterUserRequestSuccess() { + /** @var IUser|\PHPUnit_Framework_MockObject_MockObject $user */ + $user = $this->createMock(IUser::class); + $user + ->expects($this->once()) + ->method('getUID') + ->willReturn('MyUid'); + + $this->timeFactory + ->expects($this->once()) + ->method('getTime') + ->willReturn(2000); + $this->backend + ->expects($this->once()) + ->method('getAttempts') + ->with( + 'MyIdentifier', + 'ddb2ec50fa973fd49ecf3d816f677c8095143e944ad10485f30fb3dac85c13a346dace4dae2d0a15af91867320957bfd38a43d9eefbb74fe6919e15119b6d805', + 100 + ) + ->willReturn(99); + $this->backend + ->expects($this->once()) + ->method('registerAttempt') + ->with( + 'MyIdentifier', + 'ddb2ec50fa973fd49ecf3d816f677c8095143e944ad10485f30fb3dac85c13a346dace4dae2d0a15af91867320957bfd38a43d9eefbb74fe6919e15119b6d805', + 2000 + ); + + $this->limiter->registerUserRequest('MyIdentifier', 100, 100, $user); + } +} |