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/requestprocessors.yml b/_config/requestprocessors.yml index 86c2c21c3..f5f4d4df8 100644 --- a/_config/requestprocessors.yml +++ b/_config/requestprocessors.yml @@ -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: diff --git a/_config/routes.yml b/_config/routes.yml index 86c35b233..1d9cece41 100644 --- a/_config/routes.yml +++ b/_config/routes.yml @@ -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 diff --git a/src/Control/Middleware/RateLimitMiddleware.php b/src/Control/Middleware/RateLimitMiddleware.php new file mode 100644 index 000000000..2e07c8333 --- /dev/null +++ b/src/Control/Middleware/RateLimitMiddleware.php @@ -0,0 +1,72 @@ +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('

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); + } + } +} diff --git a/src/Core/Cache/RateLimiter.php b/src/Core/Cache/RateLimiter.php new file mode 100644 index 000000000..b16fc5b4c --- /dev/null +++ b/src/Core/Cache/RateLimiter.php @@ -0,0 +1,190 @@ +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/Dev/SapphireTest.php b/src/Dev/SapphireTest.php index b8ae2fb48..e052df870 100644 --- a/src/Dev/SapphireTest.php +++ b/src/Dev/SapphireTest.php @@ -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); } /**