Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 692ecef

Browse files
feature #64104 [Security] Add per-username login rate-limit to prevent brute-force attacks (ayyoub-afwallah)
This PR was merged into the 8.1 branch. Discussion ---------- [Security] Add per-username login rate-limit to prevent brute-force attacks | Q | A | ------------- | --- | Branch? | 8.1 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Fix #61932 | License | MIT Adds a third rate-limiter to DefaultLoginRateLimiter as suggested in #63997 (comment) Commits ------- a84ec30 [Security] Add per-username login rate-limit to prevent brute-force attacks
2 parents 965b0fc + a84ec30 commit 692ecef

4 files changed

Lines changed: 45 additions & 6 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,16 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal
7676
];
7777
$this->registerRateLimiter($container, $localId = '_login_local_'.$firewallName, $limiterOptions);
7878

79+
$this->registerRateLimiter($container, $usernameId = '_login_username_'.$firewallName, $limiterOptions);
80+
7981
$limiterOptions['limit'] = 5 * $config['max_attempts'];
8082
$this->registerRateLimiter($container, $globalId = '_login_global_'.$firewallName, $limiterOptions);
8183

8284
$container->register($config['limiter'] = 'security.login_throttling.'.$firewallName.'.limiter', DefaultLoginRateLimiter::class)
8385
->addArgument(new Reference('limiter.'.$globalId))
8486
->addArgument(new Reference('limiter.'.$localId))
8587
->addArgument(new Parameter('container.build_hash'))
88+
->addArgument(new Reference('limiter.'.$usernameId))
8689
;
8790
}
8891

src/Symfony/Component/Security/Http/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* Add `this` to `#[IsGranted]` subject expression variables when available
99
* Add support for closures and `this` in `#[IsCsrfTokenValid]` when evaluating its `id`
1010
* Deprecate the `$eraseCredentials` argument of `AuthenticatorManager::__construct()`, as the `eraseCredentials()` method was removed in Symfony 8.0
11+
* Add a per-username rate limit to `DefaultLoginRateLimiter` to prevent brute-force attacks from multiple IPs targeting a single account
1112

1213
8.0
1314
---

src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@
2020
/**
2121
* A default login throttling limiter.
2222
*
23-
* This limiter prevents breadth-first attacks by enforcing
24-
* a limit on username+IP and a (higher) limit on IP.
23+
* This limiter prevents breadth-first and distributed brute-force attacks by
24+
* enforcing three limits in sequence:
25+
* 1. IP only (global): blocks wide scans from a single IP;
26+
* 2. username + IP (local): blocks targeted attacks from a single IP;
27+
* 3. username only: blocks distributed botnet attacks across many IPs.
2528
*
2629
* @author Wouter de Jong <[email protected]>
2730
*/
@@ -34,6 +37,7 @@ public function __construct(
3437
private RateLimiterFactory $globalFactory,
3538
private RateLimiterFactory $localFactory,
3639
#[\SensitiveParameter] private string $secret,
40+
private ?RateLimiterFactory $usernameFactory = null,
3741
) {
3842
if (!$secret) {
3943
throw new InvalidArgumentException('A non-empty secret is required.');
@@ -45,10 +49,16 @@ protected function getLimiters(Request $request): array
4549
$username = $request->attributes->get(SecurityRequestAttributes::LAST_USERNAME, '');
4650
$username = preg_match('//u', $username) ? mb_strtolower($username, 'UTF-8') : strtolower($username);
4751

48-
return [
52+
$limiters = [
4953
$this->globalFactory->create($this->hash($request->getClientIp())),
5054
$this->localFactory->create($this->hash($username.'-'.$request->getClientIp())),
5155
];
56+
57+
if (null !== $this->usernameFactory) {
58+
$limiters[] = $this->usernameFactory->create($this->hash($username));
59+
}
60+
61+
return $limiters;
5262
}
5363

5464
private function hash(string $data): string

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,25 @@ protected function setUp(): void
3535
{
3636
$this->requestStack = new RequestStack();
3737

38+
$globalLimiter = new RateLimiterFactory([
39+
'id' => 'login',
40+
'policy' => 'fixed_window',
41+
'limit' => 6,
42+
'interval' => '1 minute',
43+
], new InMemoryStorage());
3844
$localLimiter = new RateLimiterFactory([
3945
'id' => 'login',
4046
'policy' => 'fixed_window',
4147
'limit' => 3,
4248
'interval' => '1 minute',
4349
], new InMemoryStorage());
44-
$globalLimiter = new RateLimiterFactory([
50+
$usernameLimiter = new RateLimiterFactory([
4551
'id' => 'login',
4652
'policy' => 'fixed_window',
47-
'limit' => 6,
53+
'limit' => 3,
4854
'interval' => '1 minute',
4955
], new InMemoryStorage());
50-
$limiter = new DefaultLoginRateLimiter($globalLimiter, $localLimiter, '$3cre7');
56+
$limiter = new DefaultLoginRateLimiter($globalLimiter, $localLimiter, '$3cre7', $usernameLimiter);
5157

5258
$this->listener = new LoginThrottlingListener($this->requestStack, $limiter);
5359
}
@@ -84,6 +90,25 @@ public function testPreventsLoginWithMultipleCase()
8490
$this->listener->checkPassport($this->createCheckPassportEvent($passports[0]));
8591
}
8692

93+
public function testPreventsLoginWhenOverUsernameThreshold()
94+
{
95+
$passport = $this->createPassport('wouter');
96+
// Simulate requests from different IPs
97+
for ($i = 0; $i < 3; ++$i) {
98+
$request = $this->createRequest('10.0.0.'.$i);
99+
$this->requestStack->push($request);
100+
$this->listener->checkPassport($this->createCheckPassportEvent($passport));
101+
$this->listener->onFailedLogin($this->createLoginFailedEvent($passport));
102+
$this->requestStack->pop();
103+
}
104+
105+
// A new IP should still be blocked because the username limit is reached
106+
$request = $this->createRequest('10.0.1.0');
107+
$this->requestStack->push($request);
108+
$this->expectException(TooManyLoginAttemptsAuthenticationException::class);
109+
$this->listener->checkPassport($this->createCheckPassportEvent($passport));
110+
}
111+
87112
public function testPreventsLoginWhenOverGlobalThreshold()
88113
{
89114
$request = $this->createRequest();

0 commit comments

Comments
 (0)