Skip to content

Commit 127724d

Browse files
committed
feature #38308 [Security][RateLimiter] Added request rate limiter to prevent breadth-first attacks (wouterj)
This PR was merged into the 5.2-dev branch. Discussion ---------- [Security][RateLimiter] Added request rate limiter to prevent breadth-first attacks | Q | A | ------------- | --- | Branch? | master | Bug fix? | yes | New feature? | yes | Deprecations? | no | Tickets | - | License | MIT | Doc PR | - This allows limiting on different elements of a request. The normal `CompoundLimiter` requires the same key for all its limiters. This request limiter is useful to e.g. prevent breadth-first attacks, by allowing to enforce a limit on both IP and IP+username. It can also be useful for applications using some sort of API request limiting (or e.g. file upload limiting). The default login throttling limiter will allow `max_attempts` (default: 5) attempts per minute for `username + IP` and `5 * max_attempts` for `IP`. Customizing this will require creating a new service that extends `AbstractRequestRateLimiter` and implementing `getLimiters(Request $request): LimiterInterface[]`. Commits ------- 5d03afe Added request rate limiters and improved login throttling
2 parents e8dd14a + 5d03afe commit 127724d

File tree

13 files changed

+233
-48
lines changed

13 files changed

+233
-48
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,8 +17,10 @@
1717
use Symfony\Component\DependencyInjection\ChildDefinition;
1818
use Symfony\Component\DependencyInjection\ContainerBuilder;
1919
use Symfony\Component\DependencyInjection\Reference;
20+
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;
2021
use Symfony\Component\RateLimiter\Limiter;
2122
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
23+
use Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter;
2224

2325
/**
2426
* @author Wouter de Jong <wouter@wouterj.nl>
@@ -50,7 +52,7 @@ public function addConfiguration(NodeDefinition $builder)
5052
{
5153
$builder
5254
->children()
53-
->scalarNode('limiter')->info('The name of the limiter that you defined under "framework.rate_limiter".')->end()
55+
->scalarNode('limiter')->info(sprintf('A service id implementing "%s".', RequestRateLimiterInterface::class))->end()
5456
->integerNode('max_attempts')->defaultValue(5)->end()
5557
->end();
5658
}
@@ -70,18 +72,27 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal
7072
throw new \LogicException('You must either configure a rate limiter for "security.firewalls.'.$firewallName.'.login_throttling" or install symfony/framework-bundle:^5.2.');
7173
}
7274

73-
FrameworkExtension::registerRateLimiter($container, $config['limiter'] = '_login_'.$firewallName, [
75+
$limiterOptions = [
7476
'strategy' => 'fixed_window',
7577
'limit' => $config['max_attempts'],
7678
'interval' => '1 minute',
7779
'lock_factory' => 'lock.factory',
7880
'cache_pool' => 'cache.app',
79-
]);
81+
];
82+
FrameworkExtension::registerRateLimiter($container, $localId = '_login_local_'.$firewallName, $limiterOptions);
83+
84+
$limiterOptions['limit'] = 5 * $config['max_attempts'];
85+
FrameworkExtension::registerRateLimiter($container, $globalId = '_login_global_'.$firewallName, $limiterOptions);
86+
87+
$container->register($config['limiter'] = 'security.login_throttling.'.$firewallName.'.limiter', DefaultLoginRateLimiter::class)
88+
->addArgument(new Reference('limiter.'.$globalId))
89+
->addArgument(new Reference('limiter.'.$localId))
90+
;
8091
}
8192

8293
$container
8394
->setDefinition('security.listener.login_throttling.'.$firewallName, new ChildDefinition('security.listener.login_throttling'))
84-
->replaceArgument(1, new Reference('limiter.'.$config['limiter']))
95+
->replaceArgument(1, new Reference($config['limiter']))
8596
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName]);
8697

8798
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

src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Resources/views/Login/login.html.twig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
{% if error %}
66
<div>{{ error.messageKey }}</div>
7+
<div>{{ error.messageKey|replace(error.messageData) }}</div>
78
{% endif %}
89

910
<form action="{{ path('form_login_check') }}" method="post">

src/Symfony/Bundle/SecurityBundle/Tests/Functional/FormLoginTest.php

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@ public function testFormLoginRedirectsToProtectedResourceAfterLogin(array $optio
108108
$this->assertStringContainsString('You\'re browsing to path "/protected_resource".', $text);
109109
}
110110

111-
public function testLoginThrottling()
111+
/**
112+
* @dataProvider provideInvalidCredentials
113+
*/
114+
public function testLoginThrottling($username, $password)
112115
{
113116
if (!class_exists(LoginThrottlingListener::class)) {
114117
$this->markTestSkipped('Login throttling requires symfony/security-http:^5.2');
@@ -117,17 +120,23 @@ public function testLoginThrottling()
117120
$client = $this->createClient(['test_case' => 'StandardFormLogin', 'root_config' => 'login_throttling.yml', 'enable_authenticator_manager' => true]);
118121

119122
$form = $client->request('GET', '/login')->selectButton('login')->form();
120-
$form['_username'] = 'johannes';
121-
$form['_password'] = 'wrong';
123+
$form['_username'] = $username;
124+
$form['_password'] = $password;
122125
$client->submit($form);
123126

124127
$client->followRedirect()->selectButton('login')->form();
125-
$form['_username'] = 'johannes';
126-
$form['_password'] = 'wrong';
128+
$form['_username'] = $username;
129+
$form['_password'] = $password;
127130
$client->submit($form);
128131

129132
$text = $client->followRedirect()->text(null, true);
130-
$this->assertStringContainsString('Too many failed login attempts, please try again later.', $text);
133+
$this->assertStringContainsString('Too many failed login attempts, please try again in 1 minute.', $text);
134+
}
135+
136+
public function provideInvalidCredentials()
137+
{
138+
yield 'invalid_password' => ['johannes', 'wrong'];
139+
yield 'invalid_username' => ['wrong', 'wrong'];
131140
}
132141

133142
public function provideClientOptions()

src/Symfony/Component/HttpFoundation/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* added `HeaderUtils::parseQuery()`: it does the same as `parse_str()` but preserves dots in variable names
99
* added `File::getContent()`
1010
* added ability to use comma separated ip addresses for `RequestMatcher::matchIps()`
11+
* added `RateLimiter\RequestRateLimiterInterface` and `RateLimiter\AbstractRequestRateLimiter`
1112

1213
5.1.0
1314
-----
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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\HttpFoundation\RateLimiter;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\RateLimiter\Limit;
16+
use Symfony\Component\RateLimiter\LimiterInterface;
17+
use Symfony\Component\RateLimiter\NoLimiter;
18+
19+
/**
20+
* An implementation of RequestRateLimiterInterface that
21+
* fits most use-cases.
22+
*
23+
* @author Wouter de Jong <wouter@wouterj.nl>
24+
*
25+
* @experimental in Symfony 5.2
26+
*/
27+
abstract class AbstractRequestRateLimiter implements RequestRateLimiterInterface
28+
{
29+
public function consume(Request $request): Limit
30+
{
31+
$limiters = $this->getLimiters($request);
32+
if (0 === \count($limiters)) {
33+
$limiters = [new NoLimiter()];
34+
}
35+
36+
$minimalLimit = null;
37+
foreach ($limiters as $limiter) {
38+
$limit = $limiter->consume(1);
39+
40+
if (null === $minimalLimit || $limit->getRemainingTokens() < $minimalLimit->getRemainingTokens()) {
41+
$minimalLimit = $limit;
42+
}
43+
}
44+
45+
return $minimalLimit;
46+
}
47+
48+
public function reset(): void
49+
{
50+
foreach ($this->getLimiters($request) as $limiter) {
51+
$limiter->reset();
52+
}
53+
}
54+
55+
/**
56+
* @return LimiterInterface[] a set of limiters using keys extracted from the request
57+
*/
58+
abstract protected function getLimiters(Request $request): array;
59+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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\HttpFoundation\RateLimiter;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\RateLimiter\Limit;
16+
17+
/**
18+
* A special type of limiter that deals with requests.
19+
*
20+
* This allows to limit on different types of information
21+
* from the requests.
22+
*
23+
* @author Wouter de Jong <wouter@wouterj.nl>
24+
*
25+
* @experimental in Symfony 5.2
26+
*/
27+
interface RequestRateLimiterInterface
28+
{
29+
public function consume(Request $request): Limit;
30+
31+
public function reset(): void;
32+
}

src/Symfony/Component/RateLimiter/NoLimiter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ final class NoLimiter implements LimiterInterface
2525
{
2626
public function consume(int $tokens = 1): Limit
2727
{
28-
return new Limit(\INF, new \DateTimeImmutable(), true, 'no_limit');
28+
return new Limit(\INF, new \DateTimeImmutable(), true);
2929
}
3030

3131
public function reset(): void

src/Symfony/Component/Security/Core/Exception/TooManyLoginAttemptsAuthenticationException.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,46 @@
1919
*/
2020
class TooManyLoginAttemptsAuthenticationException extends AuthenticationException
2121
{
22+
private $threshold;
23+
24+
public function __construct(int $threshold = null)
25+
{
26+
$this->threshold = $threshold;
27+
}
28+
29+
/**
30+
* {@inheritdoc}
31+
*/
32+
public function getMessageData(): array
33+
{
34+
return [
35+
'%minutes%' => $this->threshold,
36+
];
37+
}
38+
2239
/**
2340
* {@inheritdoc}
2441
*/
2542
public function getMessageKey(): string
2643
{
27-
return 'Too many failed login attempts, please try again later.';
44+
return 'Too many failed login attempts, please try again '.($this->threshold ? 'in %minutes% minute'.($this->threshold > 1 ? 's' : '').'.' : 'later.');
45+
}
46+
47+
/**
48+
* {@inheritdoc}
49+
*/
50+
public function __serialize(): array
51+
{
52+
return [$this->threshold, parent::__serialize()];
53+
}
54+
55+
/**
56+
* {@inheritdoc}
57+
*/
58+
public function __unserialize(array $data): void
59+
{
60+
[$this->threshold, $parentData] = $data;
61+
$parentData = \is_array($parentData) ? $parentData : unserialize($parentData);
62+
parent::__unserialize($parentData);
2863
}
2964
}

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

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

1414
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15-
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;
1616
use Symfony\Component\HttpFoundation\RequestStack;
17-
use Symfony\Component\RateLimiter\Limiter;
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;
21-
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
2221

2322
/**
2423
* @author Wouter de Jong <wouter@wouterj.nl>
@@ -30,7 +29,7 @@ final class LoginThrottlingListener implements EventSubscriberInterface
3029
private $requestStack;
3130
private $limiter;
3231

33-
public function __construct(RequestStack $requestStack, Limiter $limiter)
32+
public function __construct(RequestStack $requestStack, RequestRateLimiterInterface $limiter)
3433
{
3534
$this->requestStack = $requestStack;
3635
$this->limiter = $limiter;
@@ -44,33 +43,18 @@ public function checkPassport(CheckPassportEvent $event): void
4443
}
4544

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

50-
$limiter = $this->limiter->create($limiterKey);
51-
if (!$limiter->consume()->isAccepted()) {
52-
throw new TooManyLoginAttemptsAuthenticationException();
48+
$limit = $this->limiter->consume($request);
49+
if (!$limit->isAccepted()) {
50+
throw new TooManyLoginAttemptsAuthenticationException(ceil(($limit->getRetryAfter()->getTimestamp() - time()) / 60));
5351
}
5452
}
5553

56-
public function onSuccessfulLogin(LoginSuccessEvent $event): void
57-
{
58-
$limiterKey = $this->createLimiterKey($event->getAuthenticatedToken()->getUsername(), $event->getRequest());
59-
$limiter = $this->limiter->create($limiterKey);
60-
61-
$limiter->reset();
62-
}
63-
6454
public static function getSubscribedEvents(): array
6555
{
6656
return [
67-
CheckPassportEvent::class => ['checkPassport', 64],
68-
LoginSuccessEvent::class => 'onSuccessfulLogin',
57+
CheckPassportEvent::class => ['checkPassport', 2080],
6958
];
7059
}
71-
72-
private function createLimiterKey($username, Request $request): string
73-
{
74-
return $username.$request->getClientIp();
75-
}
7660
}

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