NEW RateLimiter for Security controller

This commit is contained in:
Daniel Hensby 2017-09-13 14:10:55 +01:00
parent 7b3286d512
commit 04b1bb816e
No known key found for this signature in database
GPG Key ID: B00D1E9767F0B06E
6 changed files with 274 additions and 2 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

@ -17,6 +17,12 @@ SilverStripe\Core\Injector\Injector:
SilverStripe\Control\Middleware\TrustedProxyMiddleware:
properties:
TrustedProxyIPs: '`SS_TRUSTED_PROXY_IPS`'
RateLimitedSecurityController:
class: SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter
properties:
RequestHandler: '%$SilverStripe\Security\Security'
Middlewares:
- '%$SilverStripe\Control\Middleware\RateLimitMiddleware'
---
Name: errorrequestprocessors
After:

View File

@ -11,7 +11,7 @@ After:
---
SilverStripe\Control\Director:
rules:
'Security//$Action/$ID/$OtherID': SilverStripe\Security\Security
'Security//$Action/$ID/$OtherID': '%$RateLimitedSecurityController'
'CMSSecurity//$Action/$ID/$OtherID': SilverStripe\Security\CMSSecurity
'dev': SilverStripe\Dev\DevelopmentAdmin
'interactive': SilverStripe\Dev\SapphireREPL

View File

@ -0,0 +1,72 @@
<?php
namespace SilverStripe\Control\Middleware;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\Cache\RateLimiter;
use SilverStripe\ErrorPage\ErrorPage;
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\Security\Security;
class RateLimitMiddleware implements HTTPMiddleware
{
/**
* @param HTTPRequest $request
* @param callable $delegate
* @return HTTPResponse
*/
public function process(HTTPRequest $request, callable $delegate)
{
$limiter = new RateLimiter($this->getKeyFromRequest($request), 10, 1);
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)
{
$domain = parse_url($request->getURL(), PHP_URL_HOST);
if ($currentUser = Security::getCurrentUser()) {
return md5($domain . '-' . $currentUser->ID);
}
return md5($domain . '-' . $request->getIP());
}
/**
* @return HTTPResponse
*/
protected function getErrorHTTPResponse()
{
$response = null;
if (class_exists(ErrorPage::class)) {
$response = ErrorPage::response_for(429);
}
return $response ?: new HTTPResponse('<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);
}
}
}

View File

@ -0,0 +1,190 @@
<?php
namespace SilverStripe\Core\Cache;
use Psr\SimpleCache\CacheInterface;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\FieldType\DBDatetime;
class RateLimiter
{
/**
* @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
*/
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

@ -1090,7 +1090,7 @@ class SapphireTest extends PHPUnit_Framework_TestCase implements TestOnly
*/
public static function getExtraControllers()
{
return static::$extra_controllers;
return array_merge([Security::class], static::$extra_controllers);
}
/**