mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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:
1 parent
1065ae2aed
commit
ebeaf8cffa
8 files changed
+169
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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), '/+', '._');
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user