mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
NEW RateLimiter for Security controller
This commit is contained in:
parent
7b3286d512
commit
04b1bb816e
@ -18,3 +18,7 @@ SilverStripe\Core\Injector\Injector:
|
|||||||
factory: SilverStripe\Core\Cache\CacheFactory
|
factory: SilverStripe\Core\Cache\CacheFactory
|
||||||
constructor:
|
constructor:
|
||||||
namespace: "VersionProvider_composerlock"
|
namespace: "VersionProvider_composerlock"
|
||||||
|
Psr\SimpleCache\CacheInterface.RateLimiter:
|
||||||
|
factory: SilverStripe\Core\Cache\CacheFactory
|
||||||
|
constructor:
|
||||||
|
namespace: 'ratelimiter'
|
||||||
|
@ -17,6 +17,12 @@ SilverStripe\Core\Injector\Injector:
|
|||||||
SilverStripe\Control\Middleware\TrustedProxyMiddleware:
|
SilverStripe\Control\Middleware\TrustedProxyMiddleware:
|
||||||
properties:
|
properties:
|
||||||
TrustedProxyIPs: '`SS_TRUSTED_PROXY_IPS`'
|
TrustedProxyIPs: '`SS_TRUSTED_PROXY_IPS`'
|
||||||
|
RateLimitedSecurityController:
|
||||||
|
class: SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter
|
||||||
|
properties:
|
||||||
|
RequestHandler: '%$SilverStripe\Security\Security'
|
||||||
|
Middlewares:
|
||||||
|
- '%$SilverStripe\Control\Middleware\RateLimitMiddleware'
|
||||||
---
|
---
|
||||||
Name: errorrequestprocessors
|
Name: errorrequestprocessors
|
||||||
After:
|
After:
|
||||||
|
@ -11,7 +11,7 @@ After:
|
|||||||
---
|
---
|
||||||
SilverStripe\Control\Director:
|
SilverStripe\Control\Director:
|
||||||
rules:
|
rules:
|
||||||
'Security//$Action/$ID/$OtherID': SilverStripe\Security\Security
|
'Security//$Action/$ID/$OtherID': '%$RateLimitedSecurityController'
|
||||||
'CMSSecurity//$Action/$ID/$OtherID': SilverStripe\Security\CMSSecurity
|
'CMSSecurity//$Action/$ID/$OtherID': SilverStripe\Security\CMSSecurity
|
||||||
'dev': SilverStripe\Dev\DevelopmentAdmin
|
'dev': SilverStripe\Dev\DevelopmentAdmin
|
||||||
'interactive': SilverStripe\Dev\SapphireREPL
|
'interactive': SilverStripe\Dev\SapphireREPL
|
||||||
|
72
src/Control/Middleware/RateLimitMiddleware.php
Normal file
72
src/Control/Middleware/RateLimitMiddleware.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
190
src/Core/Cache/RateLimiter.php
Normal file
190
src/Core/Cache/RateLimiter.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -1090,7 +1090,7 @@ class SapphireTest extends PHPUnit_Framework_TestCase implements TestOnly
|
|||||||
*/
|
*/
|
||||||
public static function getExtraControllers()
|
public static function getExtraControllers()
|
||||||
{
|
{
|
||||||
return static::$extra_controllers;
|
return array_merge([Security::class], static::$extra_controllers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user