Skip to content

Commit 4d9b8be

Browse files
committed
Added compound request rate limiters
This allows limiting on different elements of a request. This is usefull to e.g. prevent breadth-first attacks, by allowing to enforce a limit on both IP and IP+username.
1 parent b5587b2 commit 4d9b8be

File tree

6 files changed

+141
-22
lines changed

6 files changed

+141
-22
lines changed

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
use Symfony\Component\DependencyInjection\ChildDefinition;
1818
use Symfony\Component\DependencyInjection\ContainerBuilder;
1919
use Symfony\Component\DependencyInjection\Reference;
20+
use Symfony\Component\RateLimiter\AbstractRequestRateLimiter;
2021
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
22+
use Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter;
2123

2224
/**
2325
* @author Wouter de Jong <wouter@wouterj.nl>
@@ -49,7 +51,7 @@ public function addConfiguration(NodeDefinition $builder)
4951
{
5052
$builder
5153
->children()
52-
->scalarNode('limiter')->info('The name of the limiter that you defined under "framework.rate_limiter".')->end()
54+
->scalarNode('limiter')->info(sprintf('A service id extending from "%s".', AbstractRequestRateLimiter::class))->end()
5355
->integerNode('max_attempts')->defaultValue(5)->end()
5456
->end();
5557
}
@@ -65,18 +67,27 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal
6567
throw new \LogicException('You must either configure a rate limiter for "security.firewalls.'.$firewallName.'.login_throttling" or install symfony/framework-bundle:^5.2');
6668
}
6769

68-
FrameworkExtension::registerRateLimiter($container, $config['limiter'] = '_login_'.$firewallName, [
70+
$limiterOptions = [
6971
'strategy' => 'fixed_window',
7072
'limit' => $config['max_attempts'],
7173
'interval' => '1 minute',
7274
'lock_factory' => 'lock.factory',
7375
'cache_pool' => 'cache.app',
74-
]);
76+
];
77+
FrameworkExtension::registerRateLimiter($container, $localId = '_login_local_'.$firewallName, $limiterOptions);
78+
79+
$limiterOptions['limit'] = 5 * $config['max_attempts'];
80+
FrameworkExtension::registerRateLimiter($container, $globalId = '_login_global_'.$firewallName, $limiterOptions);
81+
82+
$container->register($config['limiter'] = 'security.login_throttling.'.$firewallName.'.limiter', DefaultLoginRateLimiter::class)
83+
->addArgument(new Reference('limiter.'.$globalId))
84+
->addArgument(new Reference('limiter.'.$localId))
85+
;
7586
}
7687

7788
$container
7889
->setDefinition('security.listener.login_throttling.'.$firewallName, new ChildDefinition('security.listener.login_throttling'))
79-
->replaceArgument(1, new Reference('limiter.'.$config['limiter']))
90+
->replaceArgument(1, new Reference($config['limiter']))
8091
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName]);
8192

8293
return [];

src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@
118118
->abstract()
119119
->args([
120120
service('request_stack'),
121-
abstract_arg('rate limiter'),
121+
abstract_arg('request rate limiter'),
122122
])
123123

124124
// Authenticators
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\RateLimiter;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
16+
/**
17+
* A special type of compound limiter that deals with requests.
18+
*
19+
* This allows to limit on different types of information
20+
* from the requests.
21+
*
22+
* @author Wouter de Jong <wouter@wouterj.nl>
23+
*/
24+
abstract class AbstractRequestRateLimiter
25+
{
26+
public function consume(Request $request): bool
27+
{
28+
$allow = true;
29+
foreach ($this->getLimiters($request) as $limiter) {
30+
$allow = $limiter->consume(1) && $allow;
31+
}
32+
33+
return $allow;
34+
}
35+
36+
public function reset(Request $request): void
37+
{
38+
foreach ($this->getLimiters($request) as $limiter) {
39+
$limiter->reset();
40+
}
41+
}
42+
43+
/**
44+
* @return LimiterInterface[]
45+
*/
46+
abstract protected function getLimiters(Request $request): array;
47+
}

src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@
1212
namespace Symfony\Component\Security\Http\EventListener;
1313

1414
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15-
use Symfony\Component\HttpFoundation\Request;
1615
use Symfony\Component\HttpFoundation\RequestStack;
17-
use Symfony\Component\RateLimiter\Limiter;
16+
use Symfony\Component\RateLimiter\AbstractRequestRateLimiter;
1817
use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException;
18+
use Symfony\Component\Security\Core\Security;
1919
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
2020
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
2121
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
@@ -30,7 +30,7 @@ final class LoginThrottlingListener implements EventSubscriberInterface
3030
private $requestStack;
3131
private $limiter;
3232

33-
public function __construct(RequestStack $requestStack, Limiter $limiter)
33+
public function __construct(RequestStack $requestStack, AbstractRequestRateLimiter $limiter)
3434
{
3535
$this->requestStack = $requestStack;
3636
$this->limiter = $limiter;
@@ -44,21 +44,19 @@ public function checkPassport(CheckPassportEvent $event): void
4444
}
4545

4646
$request = $this->requestStack->getMasterRequest();
47-
$username = $passport->getBadge(UserBadge::class)->getUserIdentifier();
48-
$limiterKey = $this->createLimiterKey($username, $request);
47+
$request->attributes->set(Security::LAST_USERNAME, $passport->getBadge(UserBadge::class)->getUserIdentifier());
4948

50-
$limiter = $this->limiter->create($limiterKey);
51-
if (!$limiter->consume()) {
49+
if (!$this->limiter->consume($request)) {
5250
throw new TooManyLoginAttemptsAuthenticationException();
5351
}
5452
}
5553

5654
public function onSuccessfulLogin(LoginSuccessEvent $event): void
5755
{
58-
$limiterKey = $this->createLimiterKey($event->getAuthenticatedToken()->getUsername(), $event->getRequest());
59-
$limiter = $this->limiter->create($limiterKey);
56+
$request = $event->getRequest();
57+
$request->attributes->set(Security::LAST_USERNAME, $event->getAuthenticatedToken()->getUsername());
6058

61-
$limiter->reset();
59+
$this->limiter->reset($request);
6260
}
6361

6462
public static function getSubscribedEvents(): array
@@ -68,9 +66,4 @@ public static function getSubscribedEvents(): array
6866
LoginSuccessEvent::class => 'onSuccessfulLogin',
6967
];
7068
}
71-
72-
private function createLimiterKey($username, Request $request): string
73-
{
74-
return $username.$request->getClientIp();
75-
}
7669
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\RateLimiter;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\RateLimiter\AbstractRequestRateLimiter;
16+
use Symfony\Component\RateLimiter\Limiter;
17+
use Symfony\Component\Security\Core\Security;
18+
19+
/**
20+
* A default login throttling limiter.
21+
*
22+
* This limiter prevents breadth-first attacks by enforcing
23+
* a limit on username+IP and a (higher) limit on IP.
24+
*
25+
* @author Wouter de Jong <wouter@wouterj.nl>
26+
*/
27+
final class DefaultLoginRateLimiter extends AbstractRequestRateLimiter
28+
{
29+
private $globalLimiter;
30+
private $localLimiter;
31+
32+
public function __construct(Limiter $globalLimiter, Limiter $localLimiter)
33+
{
34+
$this->globalLimiter = $globalLimiter;
35+
$this->localLimiter = $localLimiter;
36+
}
37+
38+
protected function getLimiters(Request $request): array
39+
{
40+
return [
41+
$this->globalLimiter->create($request->getClientIp()),
42+
$this->localLimiter->create($request->attributes->get(Security::LAST_USERNAME).$request->getClientIp()),
43+
];
44+
}
45+
}

src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
2525
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
2626
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
27+
use Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter;
2728

2829
class LoginThrottlingListenerTest extends TestCase
2930
{
@@ -34,17 +35,24 @@ protected function setUp(): void
3435
{
3536
$this->requestStack = new RequestStack();
3637

37-
$limiter = new Limiter([
38+
$localLimiter = new Limiter([
3839
'id' => 'login',
3940
'strategy' => 'fixed_window',
4041
'limit' => 3,
4142
'interval' => '1 minute',
4243
], new InMemoryStorage());
44+
$globalLimiter = new Limiter([
45+
'id' => 'login',
46+
'strategy' => 'fixed_window',
47+
'limit' => 6,
48+
'interval' => '1 minute',
49+
], new InMemoryStorage());
50+
$limiter = new DefaultLoginRateLimiter($globalLimiter, $localLimiter);
4351

4452
$this->listener = new LoginThrottlingListener($this->requestStack, $limiter);
4553
}
4654

47-
public function testPreventsLoginWhenOverThreshold()
55+
public function testPreventsLoginWhenOverLocalThreshold()
4856
{
4957
$request = $this->createRequest();
5058
$passport = $this->createPassport('wouter');
@@ -59,6 +67,21 @@ public function testPreventsLoginWhenOverThreshold()
5967
$this->listener->checkPassport($this->createCheckPassportEvent($passport));
6068
}
6169

70+
public function testPreventsLoginWhenOverGlobalThreshold()
71+
{
72+
$request = $this->createRequest();
73+
$passports = [$this->createPassport('wouter'), $this->createPassport('ryan')];
74+
75+
$this->requestStack->push($request);
76+
77+
for ($i = 0; $i < 6; ++$i) {
78+
$this->listener->checkPassport($this->createCheckPassportEvent($passports[$i % 2]));
79+
}
80+
81+
$this->expectException(TooManyLoginAttemptsAuthenticationException::class);
82+
$this->listener->checkPassport($this->createCheckPassportEvent($passports[0]));
83+
}
84+
6285
public function testSuccessfulLoginResetsCount()
6386
{
6487
$this->expectNotToPerformAssertions();

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy