An unreachable Stalwart burns login attempts and locks people out of the recovery #239

Closed
opened 2026-09-02 20:58:59 +00:00 by jcoffey-dev · 0 comments
Owner

ihasmail is a separate container from Stalwart, usually a separate host, so the upstream being briefly unreachable is an ordinary condition rather than an exotic one. Sign-in handles it almost right and gets one thing wrong.

What is already right

upstreamFailure distinguishes properly, and this should be preserved rather than changed:

Cause Response
Stalwart rejected the credentials 401 invalid_credentials
Stalwart did not answer in time 504 upstream_timeout"The mail server did not respond in time"
Anything else — refused, DNS, TLS 502 upstream_error"Could not reach the mail server"

None of those reads as a rejected password, which is the part that matters. Telling somebody their credentials are wrong when the server is down sends them resetting a password that was fine.

The bug

RateLimiter.check() consumes an attempt every time it is called — it pushes a timestamp and returns whether the count is under the limit. It is called at app.ts:200, before the upstream is contacted. loginLimiter.reset() runs only on a successful sign-in, at app.ts:224.

So every sign-in attempted while Stalwart is unreachable burns a credential attempt. After LOGIN_RATE_LIMIT of them inside the fifteen-minute window the person is locked out — and stays locked out for the rest of that window after the server comes back.

The failure mode is the ugly part. Somebody signs in during a blip, sees "could not reach the mail server", does the reasonable thing and retries a few times, and thereby converts a thirty-second outage into a fifteen-minute lockout that persists after the cause is gone. The second failure is entirely ihasmail's own doing and looks, from the outside, like the first one never got better.

The fix

Only a 401 is a failed credential attempt. A 502 or 504 is not, and should not be counted as one — the rate limiter exists to slow down password guessing, and an upstream that never answered has not told us anything about the password.

Mechanically that means either not consuming the slot until the upstream has answered, or refunding it when the answer is not a 401. The first is cleaner; the second is a smaller diff.

Worth keeping in mind while changing it: the limiter is also the defence against someone hammering the login endpoint, so "do not count upstream failures" must not become "an unreachable upstream disables rate limiting entirely". A request that fails to reach Stalwart still costs ihasmail a connection.

While in here

The messages could say the quiet part. "Could not reach the mail server" is accurate and does not imply a bad password, but it does not say the opposite either. Something closer to "The mail server is not responding. This is not a problem with your password." removes the doubt for somebody who is already worried they have forgotten it.

Related

#238 would make this sharper rather than cause it — with a domain-to-server mapping, one customer's outage locks out that domain's users while every other domain is fine, so it reads as the users being at fault. That issue now points here rather than restating it.

Rebuilt from: session transcript.

ihasmail is a separate container from Stalwart, usually a separate host, so the upstream being briefly unreachable is an ordinary condition rather than an exotic one. Sign-in handles it *almost* right and gets one thing wrong. ## What is already right `upstreamFailure` distinguishes properly, and this should be preserved rather than changed: | Cause | Response | | --- | --- | | Stalwart rejected the credentials | `401 invalid_credentials` | | Stalwart did not answer in time | `504 upstream_timeout` — *"The mail server did not respond in time"* | | Anything else — refused, DNS, TLS | `502 upstream_error` — *"Could not reach the mail server"* | None of those reads as a rejected password, which is the part that matters. Telling somebody their credentials are wrong when the server is down sends them resetting a password that was fine. ## The bug `RateLimiter.check()` **consumes** an attempt every time it is called — it pushes a timestamp and returns whether the count is under the limit. It is called at `app.ts:200`, *before* the upstream is contacted. `loginLimiter.reset()` runs only on a successful sign-in, at `app.ts:224`. So every sign-in attempted while Stalwart is unreachable burns a credential attempt. After `LOGIN_RATE_LIMIT` of them inside the fifteen-minute window the person is locked out — **and stays locked out for the rest of that window after the server comes back.** The failure mode is the ugly part. Somebody signs in during a blip, sees "could not reach the mail server", does the reasonable thing and retries a few times, and thereby converts a thirty-second outage into a fifteen-minute lockout that persists after the cause is gone. The second failure is entirely ihasmail's own doing and looks, from the outside, like the first one never got better. ## The fix Only a `401` is a failed credential attempt. A `502` or `504` is not, and should not be counted as one — the rate limiter exists to slow down password guessing, and an upstream that never answered has not told us anything about the password. Mechanically that means either not consuming the slot until the upstream has answered, or refunding it when the answer is not a 401. The first is cleaner; the second is a smaller diff. Worth keeping in mind while changing it: the limiter is also the defence against someone hammering the login endpoint, so "do not count upstream failures" must not become "an unreachable upstream disables rate limiting entirely". A request that fails to reach Stalwart still costs ihasmail a connection. ## While in here The messages could say the quiet part. *"Could not reach the mail server"* is accurate and does not imply a bad password, but it does not say the opposite either. Something closer to *"The mail server is not responding. This is not a problem with your password."* removes the doubt for somebody who is already worried they have forgotten it. ## Related #238 would make this sharper rather than cause it — with a domain-to-server mapping, one customer's outage locks out that domain's users while every other domain is fine, so it reads as the users being at fault. That issue now points here rather than restating it. <sub>Rebuilt from: session transcript.</sub>
This repo is archived. You cannot comment on issues.