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