diff --git a/_config/cache.yml b/_config/cache.yml index 20bc7c77d..4eca4560d 100644 --- a/_config/cache.yml +++ b/_config/cache.yml @@ -18,3 +18,7 @@ SilverStripe\Core\Injector\Injector: factory: SilverStripe\Core\Cache\CacheFactory constructor: namespace: "VersionProvider_composerlock" + Psr\SimpleCache\CacheInterface.RateLimiter: + factory: SilverStripe\Core\Cache\CacheFactory + constructor: + namespace: 'ratelimiter' diff --git a/_config/modules.yml b/_config/modules.yml index e4f5264c6..46ecaf9de 100644 --- a/_config/modules.yml +++ b/_config/modules.yml @@ -21,7 +21,8 @@ SilverStripe\Core\Manifest\ModuleManifest: - $project --- Name: modules-framework -After: modules-other +After: + - '#modules-other' --- SilverStripe\Core\Manifest\ModuleManifest: module_priority: diff --git a/_config/requestprocessors.yml b/_config/requestprocessors.yml index 86c2c21c3..7f11a8b7c 100644 --- a/_config/requestprocessors.yml +++ b/_config/requestprocessors.yml @@ -17,10 +17,22 @@ SilverStripe\Core\Injector\Injector: SilverStripe\Control\Middleware\TrustedProxyMiddleware: properties: TrustedProxyIPs: '`SS_TRUSTED_PROXY_IPS`' + SecurityRateLimitMiddleware: + class: SilverStripe\Control\Middleware\RateLimitMiddleware + properties: + ExtraKey: 'Security' + MaxAttempts: 10 + Decay: 1 + RateLimitedSecurityController: + class: SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter + properties: + RequestHandler: '%$SilverStripe\Security\Security' + Middlewares: + - '%$SecurityRateLimitMiddleware' --- Name: errorrequestprocessors After: - - requestprocessors + - '#requestprocessors' --- SilverStripe\Core\Injector\Injector: # Note: If Director config changes, take note it will affect this config too diff --git a/_config/routes.yml b/_config/routes.yml index 86c35b233..6d1368c95 100644 --- a/_config/routes.yml +++ b/_config/routes.yml @@ -18,3 +18,13 @@ SilverStripe\Control\Director: 'InstallerTest//$Action/$ID/$OtherID': SilverStripe\Dev\InstallerTest 'SapphireInfo//$Action/$ID/$OtherID': SilverStripe\Dev\SapphireInfo 'SapphireREPL//$Action/$ID/$OtherID': SilverStripe\Dev\SapphireREPL +--- +Name: security-limited +After: + - '#rootroutes' +Except: + environment: dev +--- +SilverStripe\Control\Director: + rules: + 'Security//$Action/$ID/$OtherID': '%$RateLimitedSecurityController' diff --git a/_config/security.yml b/_config/security.yml index 1a6b42dbd..40273a393 100644 --- a/_config/security.yml +++ b/_config/security.yml @@ -19,7 +19,7 @@ SilverStripe\Core\Injector\Injector: --- Name: coresecurity After: - - requestprocessors + - '#requestprocessors' --- SilverStripe\Core\Injector\Injector: SilverStripe\Control\Director: diff --git a/docs/en/02_Developer_Guides/09_Security/05_Rate_Limiting.md b/docs/en/02_Developer_Guides/09_Security/05_Rate_Limiting.md new file mode 100644 index 000000000..77d26adef --- /dev/null +++ b/docs/en/02_Developer_Guides/09_Security/05_Rate_Limiting.md @@ -0,0 +1,72 @@ +title: Rate Limiting +summary: SilverStripe's in built rate limiting features + +# Rate Limiting + +SilverStripe Framework comes with a [Middleware](developer_guides/controllers/middlewares/) that provides rate limiting +for the Security controller. This provides added protection to a potentially vulnerable part of a SilverStripe application +where an attacker is free to bombard your login forms or other Security endpoints. + +## Applying rate limiting to controllers + +You can apply rate limiting to other specific controllers or your entire SilverStripe application. When applying rate +limiting to other controllers you can define custom limits for each controller. + +First, you need to define your rate limit middleware with the required settings: + +```yml +SilverStripe\Core\Injector\Injector: + MyRateLimitMiddleware: + class: SilverStripe\Control\Middleware\RateLimitMiddleware + properties: + ExtraKey: 'mylimiter' # this isolates your rate limiter from others + MaxAttempts: 10 # how many attempts are allowed in a decay period + Decay: 1 # how long the decay period is in minutes +``` + +Next, you need to define your request handler which will apply the middleware to the controller: + +```yml +SilverStripe\Core\Injector\Injector: + MyRateLimitedController: + class: SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter + properties: + RequestHandler: '%$MyController' # the fully qualified class name of your controller + Middlewares: + - '%$MyRateLimitMiddleware' # the rate limiter we just defined in the last step +``` + +Finally, you need to define the custom routing: + +```yml +Director: + rules: + 'MyController//$Action/$ID/$OtherID': '%$MyRateLimitedController' +``` + +## Applying rate limiting across an entire application + +If you'd like to add rate limiting to an entire application (ie: across all routes) then you'll need to define your rate +limit middleware much like the first step outlined in the previous section and then you'll have to apply it to the entire +site as you would with any other middleware: + +```yml +SilverStripe\Core\Injector\Injector: + SilverStripe\Control\Director: + properties: + Middlewares: + SiteWideRateLimitMiddleware: '%$SiteWideRateLimitMiddleware' +``` + +## Disabling the Rate Limiter + +You may already solve the rate limiting problem on a server level and the built in rate limiting may well be redundant. +If this is the case you can turn off the rate limiting middleware by redefining the URL rules for the Security controller. + +Add the following to your config.yml: + +```yml +SilverStripe\Control\Director: + rules: + 'Security//$Action/$ID/$OtherID': SilverStripe\Security\Security +``` \ No newline at end of file diff --git a/src/Control/HTTPRequest.php b/src/Control/HTTPRequest.php index b892a81b9..b77bf0d5e 100644 --- a/src/Control/HTTPRequest.php +++ b/src/Control/HTTPRequest.php @@ -774,6 +774,14 @@ class HTTPRequest implements ArrayAccess return sizeof($this->dirParts) <= $this->unshiftedButParsedParts; } + /** + * @return string Return the host from the request + */ + public function getHost() + { + return $this->getHeader('host'); + } + /** * Returns the client IP address which originated this request. * diff --git a/src/Control/Middleware/RateLimitMiddleware.php b/src/Control/Middleware/RateLimitMiddleware.php new file mode 100644 index 000000000..a9350cad3 --- /dev/null +++ b/src/Control/Middleware/RateLimitMiddleware.php @@ -0,0 +1,168 @@ +getRateLimiter()) { + $limiter = RateLimiter::create( + $this->getKeyFromRequest($request), + $this->getMaxAttempts(), + $this->getDecay() + ); + } + if ($limiter->canAccess()) { + $limiter->hit(); + $response = $delegate($request); + } else { + $response = $this->getErrorHTTPResponse(); + } + $this->addHeadersToResponse($response, $limiter); + return $response; + } + + /** + * @param HTTPRequest $request + * @return string + */ + protected function getKeyFromRequest($request) + { + $key = $this->getExtraKey() ? $this->getExtraKey() . '-' : ''; + $key .= $request->getHost() . '-'; + if ($currentUser = Security::getCurrentUser()) { + $key .= $currentUser->ID; + } else { + $key .= $request->getIP(); + } + return md5($key); + } + + /** + * @return HTTPResponse + */ + protected function getErrorHTTPResponse() + { + return HTTPResponse::create('

429 - Too many requests

', 429); + } + + /** + * @param HTTPResponse $response + * @param RateLimiter $limiter + */ + protected function addHeadersToResponse($response, $limiter) + { + $response->addHeader('X-RateLimit-Limit', $limiter->getMaxAttempts()); + $response->addHeader('X-RateLimit-Remaining', $remaining = $limiter->getNumAttemptsRemaining()); + $ttl = $limiter->getTimeToReset(); + $response->addHeader('X-RateLimit-Reset', DBDatetime::now()->getTimestamp() + $ttl); + if ($remaining <= 0) { + $response->addHeader('Retry-After', $ttl); + } + } + + /** + * @param string $key + * @return $this + */ + public function setExtraKey($key) + { + $this->extraKey = $key; + return $this; + } + + /** + * @return string + */ + public function getExtraKey() + { + return $this->extraKey; + } + + /** + * @param int $maxAttempts + * @return $this + */ + public function setMaxAttempts($maxAttempts) + { + $this->maxAttempts = $maxAttempts; + return $this; + } + + /** + * @return int + */ + public function getMaxAttempts() + { + return $this->maxAttempts; + } + + /** + * @param int $decay Time in minutes + * @return $this + */ + public function setDecay($decay) + { + $this->decay = $decay; + return $this; + } + + /** + * @return int + */ + public function getDecay() + { + return $this->decay; + } + + /** + * @param RateLimiter $rateLimiter + * @return $this + */ + public function setRateLimiter($rateLimiter) + { + $this->rateLimiter = $rateLimiter; + return $this; + } + + /** + * @return RateLimiter|null + */ + public function getRateLimiter() + { + return $this->rateLimiter; + } +} diff --git a/src/Core/Cache/RateLimiter.php b/src/Core/Cache/RateLimiter.php new file mode 100644 index 000000000..5735b81c3 --- /dev/null +++ b/src/Core/Cache/RateLimiter.php @@ -0,0 +1,193 @@ +setIdentifier($identifier); + $this->setMaxAttempts($maxAttempts); + $this->setDecay($decay); + } + + /** + * @return CacheInterface + */ + public function getCache() + { + if (!$this->cache) { + $this->setCache(Injector::inst()->create(CacheInterface::class . '.RateLimiter')); + } + return $this->cache; + } + + /** + * @param CacheInterface $cache + * + * @return $this + */ + public function setCache($cache) + { + $this->cache = $cache; + return $this; + } + + /** + * @return string + */ + public function getIdentifier() + { + return $this->identifier; + } + + /** + * @param string $identifier + * @return $this + */ + public function setIdentifier($identifier) + { + $this->identifier = $identifier; + return $this; + } + + /** + * @return int + */ + public function getMaxAttempts() + { + return $this->maxAttempts; + } + + /** + * @param int $maxAttempts + * @return $this + */ + public function setMaxAttempts($maxAttempts) + { + $this->maxAttempts = $maxAttempts; + return $this; + } + + /** + * @return int + */ + public function getDecay() + { + return $this->decay; + } + + /** + * @param int $decay + * @return $this + */ + public function setDecay($decay) + { + $this->decay = $decay; + return $this; + } + + /** + * @return int + */ + public function getNumAttempts() + { + return $this->getCache()->get($this->getIdentifier(), 0); + } + + /** + * @return int + */ + public function getNumAttemptsRemaining() + { + return max(0, $this->getMaxAttempts() - $this->getNumAttempts()); + } + + /** + * @return int + */ + public function getTimeToReset() + { + if ($expiry = $this->getCache()->get($this->getIdentifier() . '-timer')) { + return $expiry - DBDatetime::now()->getTimestamp(); + } + return 0; + } + + /** + * @return $this + */ + public function clearAttempts() + { + $this->getCache()->delete($this->getIdentifier()); + return $this; + } + + /** + * Store a hit in the rate limit cache + * + * @return $this + */ + public function hit() + { + if (!$this->getCache()->has($this->getIdentifier())) { + $ttl = $this->getDecay() * 60; + $expiry = DBDatetime::now()->getTimestamp() + $ttl; + $this->getCache()->set($this->getIdentifier() . '-timer', $expiry, $ttl); + } else { + $expiry = $this->getCache()->get($this->getIdentifier() . '-timer'); + $ttl = $expiry - DBDatetime::now()->getTimestamp(); + } + $this->getCache()->set($this->getIdentifier(), $this->getNumAttempts() + 1, $ttl); + return $this; + } + + /** + * @return bool + */ + public function canAccess() + { + if ($this->getNumAttempts() >= $this->getMaxAttempts()) { + // if the timer cache item still exists then they are locked out + if ($this->getCache()->has($this->getIdentifier() . '-timer')) { + return false; + } + // the timer key has expired so we can clear their attempts and start again + $this->clearAttempts(); + } + return true; + } +} diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php index cf6c3c3b7..78a5cff7f 100644 --- a/src/ORM/FieldType/DBDatetime.php +++ b/src/ORM/FieldType/DBDatetime.php @@ -190,13 +190,14 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider */ public static function set_mock_now($datetime) { - if ($datetime instanceof DBDatetime) { - self::$mock_now = $datetime; - } elseif (is_string($datetime)) { - self::$mock_now = DBField::create_field('Datetime', $datetime); - } else { - throw new InvalidArgumentException('DBDatetime::set_mock_now(): Wrong format: ' . $datetime); + if (!$datetime instanceof DBDatetime) { + $value = $datetime; + $datetime = DBField::create_field('Datetime', $datetime); + if ($datetime === false) { + throw new InvalidArgumentException('DBDatetime::set_mock_now(): Wrong format: ' . $value); + } } + self::$mock_now = $datetime; } /** diff --git a/tests/php/Control/Middleware/Control/TestController.php b/tests/php/Control/Middleware/Control/TestController.php new file mode 100644 index 000000000..d906dc0df --- /dev/null +++ b/tests/php/Control/Middleware/Control/TestController.php @@ -0,0 +1,18 @@ +set(Injector::class, 'TestRateLimitMiddleware', [ + 'class' => RateLimitMiddleware::class, + 'properties' => [ + 'ExtraKey' => 'test', + 'MaxAttempts' => 2, + 'Decay' => 1, + ], + ]); + Config::modify()->set(Injector::class, 'RateLimitTestController', [ + 'class' => RequestHandlerMiddlewareAdapter::class, + 'properties' => [ + 'RequestHandler' => '%$' . TestController::class, + 'Middlewares' => [ + '%$TestRateLimitMiddleware' + ], + ], + ]); + } + + protected function getExtraRoutes() + { + $rules = parent::getExtraRoutes(); + $rules['TestController//$Action/$ID/$OtherID'] = '%$RateLimitTestController'; + return $rules; + } + + public function testRequest() + { + $response = $this->get('TestController'); + $this->assertFalse($response->isError()); + $this->assertEquals(2, $response->getHeader('X-RateLimit-Limit')); + $this->assertEquals(1, $response->getHeader('X-RateLimit-Remaining')); + $this->assertEquals(DBDatetime::now()->getTimestamp() + 60, $response->getHeader('X-RateLimit-Reset')); + $this->assertEquals('Success', $response->getBody()); + $response = $this->get('TestController'); + $this->assertFalse($response->isError()); + $this->assertEquals(0, $response->getHeader('X-RateLimit-Remaining')); + $response = $this->get('TestController'); + $this->assertTrue($response->isError()); + $this->assertEquals(429, $response->getStatusCode()); + $this->assertEquals(60, $response->getHeader('retry-after')); + $this->assertNotEquals('Success', $response->getBody()); + } +} diff --git a/tests/php/Core/Cache/RateLimiterTest.php b/tests/php/Core/Cache/RateLimiterTest.php new file mode 100644 index 000000000..ec2a80527 --- /dev/null +++ b/tests/php/Core/Cache/RateLimiterTest.php @@ -0,0 +1,127 @@ +setCache($cache); + $this->assertEquals('test', $rateLimiter->getIdentifier()); + $this->assertEquals(5, $rateLimiter->getMaxAttempts()); + $this->assertEquals(1, $rateLimiter->getDecay()); + } + + public function testGetNumberOfAttempts() + { + $cache = new ArrayCache(); + $rateLimiter = new RateLimiter( + 'test', + 5, + 1 + ); + $rateLimiter->setCache($cache); + for ($i = 0; $i < 7; ++$i) { + $this->assertEquals($i, $rateLimiter->getNumAttempts()); + $rateLimiter->hit(); + } + } + + public function testGetNumAttemptsRemaining() + { + $cache = new ArrayCache(); + $rateLimiter = new RateLimiter( + 'test', + 1, + 1 + ); + $rateLimiter->setCache($cache); + $this->assertEquals(1, $rateLimiter->getNumAttemptsRemaining()); + $rateLimiter->hit(); + $this->assertEquals(0, $rateLimiter->getNumAttemptsRemaining()); + $rateLimiter->hit(); + $this->assertEquals(0, $rateLimiter->getNumAttemptsRemaining()); + } + + public function testGetTimeToReset() + { + $cache = new ArrayCache(); + $rateLimiter = new RateLimiter( + 'test', + 1, + 1 + ); + $rateLimiter->setCache($cache); + $this->assertEquals(0, $rateLimiter->getTimeToReset()); + $rateLimiter->hit(); + $this->assertEquals(60, $rateLimiter->getTimeToReset()); + DBDatetime::set_mock_now(DBDatetime::now()->getTimestamp() + 30); + $this->assertEquals(30, $rateLimiter->getTimeToReset()); + } + + public function testClearAttempts() + { + $cache = new ArrayCache(); + $rateLimiter = new RateLimiter( + 'test', + 1, + 1 + ); + $rateLimiter->setCache($cache); + for ($i = 0; $i < 5; ++$i) { + $rateLimiter->hit(); + } + $this->assertEquals(5, $rateLimiter->getNumAttempts()); + $rateLimiter->clearAttempts(); + $this->assertEquals(0, $rateLimiter->getNumAttempts()); + } + + public function testHit() + { + $cache = new ArrayCache(); + $rateLimiter = new RateLimiter( + 'test', + 1, + 1 + ); + $rateLimiter->setCache($cache); + $this->assertFalse($cache->has('test')); + $this->assertFalse($cache->has('test-timer')); + $rateLimiter->hit(); + $this->assertTrue($cache->has('test')); + $this->assertTrue($cache->has('test-timer')); + } + + public function testCanAccess() + { + $cache = new ArrayCache(); + $rateLimiter = new RateLimiter( + 'test', + 1, + 1 + ); + $rateLimiter->setCache($cache); + $this->assertTrue($rateLimiter->canAccess()); + $rateLimiter->hit(); + $this->assertFalse($rateLimiter->canAccess()); + } +}