fix(security): add a per-username login limit

Symfony's default login throttle counts per username+IP and per IP, so a
guesser on many addresses got five tries per address at the same account,
while the comment in security.yaml claimed it was per username. A custom
LoginRateLimiter keeps both and adds a username-only limit (20 per 15
minutes, set above what a mistyping person reaches since it can also be
spent by someone else). Comment and handbook corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-09-23 19:37:15 +02:00
1 parent 1065ae2aed
commit ebeaf8cffa
8 files changed
+169 -19

No files matched your search

+22
View File
@@ -20,6 +20,28 @@ framework:
limit: 5
interval: '15 minutes'
# The password form, in App\Security\LoginRateLimiter. The first two
# are Symfony's own login_throttling defaults, restated because a custom
# limiter replaces them: five per username+IP, and five times that per
# IP as the backstop for one client spraying many accounts.
login_user_ip:
policy: 'fixed_window'
limit: 5
interval: '15 minutes'
login_ip:
policy: 'fixed_window'
limit: 25
interval: '15 minutes'
# The one the defaults lack: per username, whatever the address. Without
# it a guesser on many addresses had five per address against one
# account. Twenty, not five, because this one can also be spent by
# somebody else — failing on purpose locks the owner out for the
# window — so it sits well above anything a person mistyping reaches.
login_user:
policy: 'fixed_window'
limit: 20
interval: '15 minutes'
# The public booking POST. It is the only endpoint on this install that
# an unauthenticated stranger can use to create database rows AND cause
# an email to be sent, which is the definition of a spam vector: one
+10 -7
View File
@@ -57,17 +57,20 @@ security:
# the other half, and is ended by UserProvider::refreshUser().
user_checker: App\Security\UserChecker
# A password form on an instance meant to be reachable from the
# internet, with no limit on how fast it could be guessed. The
# per-username limit is the one that matters; the global IP limit
# is the backstop for someone spraying one password across many
# addresses, and is deliberately looser so a household behind one
# NAT address cannot lock itself out.
# internet, with no limit on how fast it could be guessed. Three
# limits, in App\Security\LoginRateLimiter (numbers in
# rate_limiter.yaml): per username+IP, the tight one; per IP, the
# backstop for one client spraying a password across many accounts,
# looser so a household behind one NAT address cannot lock itself
# out; and per username alone, so guesses spread across many
# addresses still hit a ceiling. Symfony's default has only the
# first two, which is per username+IP and not per username as this
# comment used to claim.
#
# Note this does NOT cover the 2FA code form — that is a separate
# firewall listener with its own throttle, see below.
login_throttling:
max_attempts: 5
interval: '15 minutes'
limiter: App\Security\LoginRateLimiter
# Opt-in via the "keep me logged in" checkbox on the login form.
# Signature-based tokens: no storage, and changing the password
# invalidates every cookie that was issued for it.
+5 -2
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/security.md sha1:3846154feea3ee70da0ee20ea88354b20ef00d60 -->
<!-- translated-from: features/security.md sha1:ab74a7237345f08d46c12179468fa91366bb0894 -->
# Sicherheit
@@ -96,7 +96,10 @@ der Begründung, dass ein unter dem alten Geheimnis vertrautes Gerät die Abfrag
Das Anmeldeformular drosselt bei fünf Versuchen je fünfzehn Minuten und Adresse, mit einer
lockereren Absicherung je Herkunftsadresse für jemanden, der ein Passwort über viele Konten
streut — lockerer, damit ein Haushalt hinter einer NAT-Adresse sich nicht selbst aussperrt.
streut — lockerer, damit ein Haushalt hinter einer NAT-Adresse sich nicht selbst aussperrt. Eine
dritte Grenze zählt je Konto, egal von welcher Adresse: zwanzig Fehlversuche in fünfzehn Minuten,
damit auch das Raten von vielen Adressen zugleich aufhört. Die kann auch jemand anderes für dich
aufbrauchen, deshalb liegt sie weit über dem, was du beim Vertippen je erreichst.
**Angemeldet bleiben** stellt ein Cookie mit 60 Tagen Lebensdauer aus. Es beruht auf einer
Signatur und ist nicht gespeichert, was bedeutet, dass eine Passwortänderung jedes ausgestellte
+7 -5
View File
@@ -1,4 +1,4 @@
<!-- translated-from: internals/security-model.md sha1:fb37ca735d192ed5404198332e048e55fd00b8b5 -->
<!-- translated-from: internals/security-model.md sha1:f425b788c442ece83228dfdeba34bbf195cee3c1 -->
# Sicherheitsmodell
Verschlüsselung ruhender Daten und die Prüfung, die den Start ohne brauchbaren Schlüssel
@@ -192,10 +192,12 @@ diejenige agiert, der das Token gehört. `lastUsedAt` wird höchstens alle
`LAST_USED_TTL_SECONDS` (300) neu geschrieben, denn Clients fragen ständig nach, und jeder
JMAP-Aufruf löste sonst einen Schreibvorgang aus.
**`main` bringt eine Anmeldedrosselung mit**, 5 Versuche je 15 Minuten. Die Grenze je Benutzername
ist die, auf die es ankommt; die globale IP-Grenze ist die Absicherung gegen das Durchprobieren
eines Passworts über viele Adressen und ist bewusst lockerer, damit ein Haushalt hinter einer
NAT-Adresse sich nicht selbst aussperren kann. Remember-me arbeitet signaturbasiert mit 60 Tagen
**`main` bringt eine Anmeldedrosselung mit**, drei Grenzen in `App\Security\LoginRateLimiter`:
5 je 15 Minuten je Benutzername und Adresse; 25 je Adresse, die Absicherung gegen einen Client,
der ein Passwort über viele Konten streut, lockerer, damit ein Haushalt hinter einer NAT-Adresse
sich nicht selbst aussperrt; und 20 je Benutzername, egal von welcher Adresse, damit auch über
viele Adressen verteilte Versuche an eine Decke stoßen. Symfonys Vorgabe kennt nur die ersten
beiden — diese Seite nannte das früher „je Benutzername“, was es nicht ist. Remember-me arbeitet signaturbasiert mit 60 Tagen
Lebensdauer: keine Speicherung, und ein Passwortwechsel entwertet jedes dafür ausgegebene Cookie.
### Zugriffssteuerung, und die Endpunkte, die keine Session halten können
+4 -1
View File
@@ -83,7 +83,10 @@ trusted under the old secret would otherwise keep skipping the prompt.
The login form throttles at five attempts per fifteen minutes per address, with a looser
per-address-of-origin backstop for somebody spraying one password across many accounts — looser so
a household behind one NAT address cannot lock itself out.
a household behind one NAT address cannot lock itself out. A third limit counts per account
whatever the address: twenty failures in fifteen minutes, so guessing from many addresses at once
still stops. That one can be spent by somebody else, which is why it is set well above anything a
person mistyping reaches.
**Keep me logged in** issues a cookie lasting 60 days. It is signature-based rather than stored,
which means changing your password invalidates every one that was issued. It does **not** walk past
+6 -4
View File
@@ -173,10 +173,12 @@ sending the wrong address is told so, instead of silently operating as whoever t
belongs to. `lastUsedAt` is rewritten at most every `LAST_USED_TTL_SECONDS` (300), because
clients poll constantly and every JMAP call would otherwise issue a write.
**`main` carries login throttling** at 5 attempts per 15 minutes. The per-username limit is
the one that matters; the global IP limit is the backstop for spraying one password across
many addresses and is deliberately looser, so a household behind one NAT address cannot lock
itself out. Remember-me is signature-based with a 60-day lifetime: no storage, and changing
**`main` carries login throttling**, three limits in `App\Security\LoginRateLimiter`: 5 per
15 minutes per username and address; 25 per address, the backstop for one client spraying a
password across many accounts, looser so a household behind one NAT address cannot lock itself
out; and 20 per username whatever the address, so guesses spread across many addresses still
hit a ceiling. Symfony's default has only the first two — this page used to call that "per
username", which it is not. Remember-me is signature-based with a 60-day lifetime: no storage, and changing
the password invalidates every cookie issued for it.
### Access control, and the endpoints that cannot hold a session
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RateLimiter\AbstractRequestRateLimiter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
/**
* The password form's throttle: per username+IP, per IP, and per username.
*
* Symfony's DefaultLoginRateLimiter keys on username+IP and on IP, and the
* comment in security.yaml said "per username" about it. It is not: an
* attacker spreading guesses across addresses gets max_attempts per address
* against the same account, which with a botnet or an IPv6 /64 is no limit at
* all. The third limiter keys on the username alone, so an account has a
* ceiling however many addresses are guessing at it.
*
* Deliberately looser than the username+IP one. A per-account limit is also a
* way to lock the owner out by failing on their behalf, so it is set where a
* real person never reaches it and a guessing campaign does quickly.
*/
final class LoginRateLimiter extends AbstractRequestRateLimiter
{
public function __construct(
private readonly RateLimiterFactoryInterface $loginIpLimiter,
private readonly RateLimiterFactoryInterface $loginUserIpLimiter,
private readonly RateLimiterFactoryInterface $loginUserLimiter,
#[Autowire('%kernel.secret%')]
#[\SensitiveParameter]
private readonly string $secret,
) {
}
/**
* Clears the per-account counts on a successful login, not the per-IP one.
*
* The IP limit bounds how many accounts one client may try, so it has to
* survive any one of them succeeding — the same reasoning Symfony's default
* applies. The username-only count is cleared too: whoever just signed in
* knew the password, so what it was guarding against is moot.
*/
public function reset(Request $request): void
{
$this->userIpLimiter($request)->reset();
$this->userLimiter($request)->reset();
}
/**
* @return list<LimiterInterface>
*/
protected function getLimiters(Request $request): array
{
return [
$this->loginIpLimiter->create($this->hash((string) $request->getClientIp())),
$this->userIpLimiter($request),
$this->userLimiter($request),
];
}
private function userIpLimiter(Request $request): LimiterInterface
{
return $this->loginUserIpLimiter->create($this->hash($this->username($request).'-'.$request->getClientIp()));
}
private function userLimiter(Request $request): LimiterInterface
{
return $this->loginUserLimiter->create($this->hash($this->username($request)));
}
private function username(Request $request): string
{
$username = (string) $request->attributes->get(SecurityRequestAttributes::LAST_USERNAME, '');
return 1 === preg_match('//u', $username) ? mb_strtolower($username, 'UTF-8') : strtolower($username);
}
/** Hashed like Symfony's default, so no address or username sits in the cache in the clear. */
private function hash(string $data): string
{
return strtr(substr(base64_encode(hash_hmac('sha256', $data, $this->secret, true)), 0, 8), '/+', '._');
}
}
+27
View File
@@ -69,6 +69,33 @@ final class LoginThrottlingTest extends WebTestCase
);
}
/**
* Spreading the guesses across addresses does not escape the limit.
*
* Symfony's default counts per username+IP, so each new address bought
* five more guesses at the same account. The username-only limiter (20 in
* rate_limiter.yaml) is the ceiling; one fresh address per attempt keeps
* the other two limiters out of it.
*/
public function testGuessesFromManyAddressesAreEventuallyRefused(): void
{
$client = static::createClient();
$client->followRedirects(true);
for ($attempt = 0; $attempt < 22; $attempt++) {
$client->request('POST', '/login', [
'email' => 'spread-probe@example.test',
'password' => 'definitely-the-wrong-password',
], [], ['REMOTE_ADDR' => '203.0.113.' . ($attempt + 1)]);
}
self::assertStringContainsString(
'Too many failed login attempts',
(string) $client->getResponse()->getContent(),
'one account took 22 guesses from 22 addresses without throttling',
);
}
/**
* The limit is per username, not per IP.
*