[Security] Dispatch RateLimitExceededEvent from LoginThrottlingListener - #65628
#65628[Security] Dispatch RateLimitExceededEvent from LoginThrottlingListener#65628mariecharles wants to merge 1 commit intosymfony:8.2symfony/symfony:8.2from mariecharles:feat/rate-limit-exceeded-event-from-login-throttlingmariecharles/symfony:feat/rate-limit-exceeded-event-from-login-throttlingCopy head branch name to clipboard
Conversation
ed19088 to
2ca39cb
Compare
nicolas-grekas
left a comment
There was a problem hiding this comment.
Thank you for this. The direction is right: #[RateLimit] has this hook and login throttling does not. Three points before it can go in.
The key does not name what was rejected. DefaultLoginRateLimiter consumes two limiters, a global one keyed on the IP and a local one keyed on the username plus the IP, and AbstractRequestRateLimiter returns the lower of the two. The listener always reports <username>-<ip>. In a breadth-first attack (one IP, a new username each attempt) only the global limiter rejects:
attempt 5: REJECTED, event key='user5-192.168.1.0' limit=5 remaining=0
attempt 6: REJECTED, event key='user6-192.168.1.0' limit=5 remaining=0
attempt 7: REJECTED, event key='user7-192.168.1.0' limit=5 remaining=0
limit=5 is the global limiter, yet the key names a username that limiter never saw. The real keys here are login-rTViA0.m and login-fc_wc5jm; neither matches, and the username is not lowercased the way the local limiter does it. A consumer grouping on the key gets one entry per attempt and never learns that the IP is blocked. getKey() documents itself as "the key that was consumed, when known", and here it is not known, so please pass no key at all.
Please pass the limiter name instead. LoginThrottlingFactory::createAuthenticator() already has $config['limiter'] (security.login_throttling.<firewall>.limiter) and already calls replaceArgument(). That gives consumers something exact to attribute on, and it names the firewall too.
One point for the docs. With the default peekable limiter, the RateLimit carried by the event has isAccepted() === true; the rejection comes from 0 === getRemainingTokens(). So the natural check if (!$event->getRateLimit()->isAccepted()) silently drops every login-throttling event. Please say so in the documentation pull request.
Two smaller things while you are in there:
service('event_dispatcher')should be->nullOnInvalid(), matching how the same optional argument is wired forContextListenerandSwitchUserListenerinsecurity_listeners.php.- The unit test passes the dispatcher in by hand, so it cannot tell the global dispatcher from the firewall one. Wiring
security.event_dispatcher.<firewall>by mistake would stay green, becauseRegisterGlobalSecurityEventListenersPassdoes not bubble this event. A functional test inFormLoginTestcovers that.
Last thing: the description says nothing else exposes the rejection. LoginFailureEvent already carries it, with the firewall name, the request, the passport and the retry-after, and it bubbles to the global dispatcher. What is new here is the RateLimit object and the hook shared with #[RateLimit]. Worth rewording, so that reviewers weigh the right claim.
When login throttling rejects an attempt,
LoginThrottlingListenerthrows aTooManyLoginAttemptsAuthenticationExceptionand nothing else is exposed.This dispatches now a
Symfony\Component\RateLimiter\Event\RateLimitExceededEventright before the exception is thrown, mirroring the addition made toRateLimitAttributeListener.This is informational only. The rejection always happens but the event gives now visibility into it, providing a way to log it, trigger an alert or measure it.