Merge pull request #7373 from dhensby/pulls/4/rate-limit-security

NEW RateLimiter for Security controller
This commit is contained in:
Damian Mooyman 2017-09-28 11:01:37 +13:00 committed by GitHub
commit da27948777
13 changed files with 690 additions and 9 deletions

View File

@ -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'

View File

@ -21,7 +21,8 @@ SilverStripe\Core\Manifest\ModuleManifest:
- $project
---
Name: modules-framework
After: modules-other
After:
- '#modules-other'
---
SilverStripe\Core\Manifest\ModuleManifest:
module_priority:

View File

@ -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

View File

@ -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'

View File

@ -19,7 +19,7 @@ SilverStripe\Core\Injector\Injector:
---
Name: coresecurity
After:
- requestprocessors
- '#requestprocessors'
---
SilverStripe\Core\Injector\Injector:
SilverStripe\Control\Director:

View File

@ -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
```

View File

@ -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.
*

View File

@ -0,0 +1,168 @@
<?php
namespace SilverStripe\Control\Middleware;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\Cache\RateLimiter;
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\Security\Security;
class RateLimitMiddleware implements HTTPMiddleware
{
/**
* @var string Optional extra data to add to request key generation
*/
private $extraKey;
/**
* @var int Maximum number of attempts within the decay period
*/
private $maxAttempts = 10;
/**
* @var int The decay period (in minutes)
*/
private $decay = 1;
/**
* @var RateLimiter|null
*/
private $rateLimiter;
/**
* @param HTTPRequest $request
* @param callable $delegate
* @return HTTPResponse
*/
public function process(HTTPRequest $request, callable $delegate)
{
if (!$limiter = $this->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('<h1>429 - Too many requests</h1>', 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;
}
}

View File

@ -0,0 +1,193 @@
<?php
namespace SilverStripe\Core\Cache;
use Psr\SimpleCache\CacheInterface;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\FieldType\DBDatetime;
class RateLimiter
{
use Injectable;
/**
* @var CacheInterface
*/
private $cache;
/**
* @var string
*/
private $identifier;
/**
* @var int Maximum number of attempts allowed
*/
private $maxAttempts;
/**
* @var int How long the rate limit lasts for in minutes
*/
private $decay;
/**
* RateLimiter constructor.
* @param string $identifier
* @param int $maxAttempts
* @param int $decay
*/
public function __construct($identifier, $maxAttempts, $decay)
{
$this->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;
}
}

View File

@ -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;
}
/**

View File

@ -0,0 +1,18 @@
<?php
namespace SilverStripe\Control\Tests\Middleware\Control;
use SilverStripe\Control\Controller;
class TestController extends Controller
{
public function index($request)
{
return "Success";
}
public function Link($action = null)
{
return Controller::join_links('TestController', $action);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace SilverStripe\Control\Tests\Middleware;
use SilverStripe\Control\Middleware\RateLimitMiddleware;
use SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter;
use SilverStripe\Control\Tests\Middleware\Control\TestController;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\FunctionalTest;
use SilverStripe\ORM\FieldType\DBDatetime;
class RateLimitMiddlewareTest extends FunctionalTest
{
protected static $extra_controllers = [
TestController::class,
];
protected function setUp()
{
parent::setUp();
DBDatetime::set_mock_now('2017-09-27 00:00:00');
Config::modify()->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());
}
}

View File

@ -0,0 +1,127 @@
<?php
namespace SilverStripe\Core\Tests\Cache;
use Psr\SimpleCache\CacheInterface;
use SilverStripe\Core\Cache\RateLimiter;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\FieldType\DBDatetime;
use Symfony\Component\Cache\Simple\ArrayCache;
class RateLimiterTest extends SapphireTest
{
protected function setUp()
{
parent::setUp();
DBDatetime::set_mock_now('2017-09-27 00:00:00');
}
public function testConstruct()
{
$cache = new ArrayCache();
$rateLimiter = new RateLimiter(
'test',
5,
1
);
$rateLimiter->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());
}
}