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/modules.yml b/_config/modules.yml
index e4f5264c6..46ecaf9de 100644
--- a/_config/modules.yml
+++ b/_config/modules.yml
@@ -21,7 +21,8 @@ SilverStripe\Core\Manifest\ModuleManifest:
- $project
---
Name: modules-framework
-After: modules-other
+After:
+ - '#modules-other'
---
SilverStripe\Core\Manifest\ModuleManifest:
module_priority:
diff --git a/_config/requestprocessors.yml b/_config/requestprocessors.yml
index 86c2c21c3..7f11a8b7c 100644
--- a/_config/requestprocessors.yml
+++ b/_config/requestprocessors.yml
@@ -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
diff --git a/_config/routes.yml b/_config/routes.yml
index 86c35b233..6d1368c95 100644
--- a/_config/routes.yml
+++ b/_config/routes.yml
@@ -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'
diff --git a/_config/security.yml b/_config/security.yml
index 1a6b42dbd..40273a393 100644
--- a/_config/security.yml
+++ b/_config/security.yml
@@ -19,7 +19,7 @@ SilverStripe\Core\Injector\Injector:
---
Name: coresecurity
After:
- - requestprocessors
+ - '#requestprocessors'
---
SilverStripe\Core\Injector\Injector:
SilverStripe\Control\Director:
diff --git a/docs/en/02_Developer_Guides/09_Security/05_Rate_Limiting.md b/docs/en/02_Developer_Guides/09_Security/05_Rate_Limiting.md
new file mode 100644
index 000000000..77d26adef
--- /dev/null
+++ b/docs/en/02_Developer_Guides/09_Security/05_Rate_Limiting.md
@@ -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
+```
\ No newline at end of file
diff --git a/src/Control/HTTPRequest.php b/src/Control/HTTPRequest.php
index b892a81b9..b77bf0d5e 100644
--- a/src/Control/HTTPRequest.php
+++ b/src/Control/HTTPRequest.php
@@ -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.
*
diff --git a/src/Control/Middleware/RateLimitMiddleware.php b/src/Control/Middleware/RateLimitMiddleware.php
new file mode 100644
index 000000000..a9350cad3
--- /dev/null
+++ b/src/Control/Middleware/RateLimitMiddleware.php
@@ -0,0 +1,168 @@
+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('
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);
+ }
+ }
+
+ /**
+ * @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;
+ }
+}
diff --git a/src/Core/Cache/RateLimiter.php b/src/Core/Cache/RateLimiter.php
new file mode 100644
index 000000000..5735b81c3
--- /dev/null
+++ b/src/Core/Cache/RateLimiter.php
@@ -0,0 +1,193 @@
+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/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php
index cf6c3c3b7..78a5cff7f 100644
--- a/src/ORM/FieldType/DBDatetime.php
+++ b/src/ORM/FieldType/DBDatetime.php
@@ -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;
}
/**
diff --git a/tests/php/Control/Middleware/Control/TestController.php b/tests/php/Control/Middleware/Control/TestController.php
new file mode 100644
index 000000000..d906dc0df
--- /dev/null
+++ b/tests/php/Control/Middleware/Control/TestController.php
@@ -0,0 +1,18 @@
+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());
+ }
+}
diff --git a/tests/php/Core/Cache/RateLimiterTest.php b/tests/php/Core/Cache/RateLimiterTest.php
new file mode 100644
index 000000000..ec2a80527
--- /dev/null
+++ b/tests/php/Core/Cache/RateLimiterTest.php
@@ -0,0 +1,127 @@
+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());
+ }
+}