Repository: Mailu/Mailu

Issue:
Webmail is not exempt from rate limiting
## Environment & Version

- `docker compose version`:  _Docker Compose version 2.23.3_
- Version: mailu `2.0.34`

## Description
Trying to open my webmail (roundcube) the browser just showed an error due to _too many redirects_ - which were to `sso.php` of the webmailer.

Debugging this I found the following to be the reason:
> front-1       |  [info] 15#15: *153382 client login failed: "Temporary authentication failure (rate-limit)" while in http auth state, client: **172.29.0.3**, server: 0.0.0.0:10143, login: "user@example.net"

Where `172.29.0.3` is the IP of the webmail container.

As far as I could debug this everything else was working fine, `sso.php` could correctly get valid credentials provided by `front` via HTTP headers but trying to use them would fail since the webmail container was rate limited. The failed login would the redirect again to `sso.php` - in a loop...

## Replication Steps
Unfortunately I have no idea how the webmail container's IP could end up on the rate limited list...
The webmail container should only ever try to login with credentials provided by `front` via HTTP headers - which then should always be valid

## Observed behaviour
Webmailer was blocked by rate limiting, preventing it from successfully authenticate, causing its login in to fail and damning the browser into an infinite redirection loop.

## Expected behaviour
The webmailer should not be blocked by rate limiting since the credentials are passed from an already valid login via SSO anyway.

## Possible solutions
1. prevent the webmailer from hitting the rate limits: this requires some more debugging,since I don't know how it could end up rate limited in the first place since every login it tries should be successful...
2. exempt the webmail container from rate limits: this is the workaround I used now by adapting my `docker-compose.yml` config to give the network used by the webmailer a known subnet and exempting it from rate limits:
~~~diff
--- a/mailu.env
+++ b/.mailu.env
@@ -38,6 +38,10 @@ AUTH_RATELIMIT_IP=60/hour
 # Authentication rate limit per user (regardless of the source-IP)
 AUTH_RATELIMIT_USER=100/day
 
+# exempt webmail container from rate limiting
+WEBMAIL_SUBNET=172.29.0.0/24
+AUTH_RATELIMIT_EXEMPTION=$WEBMAIL_SUBNET
+
 # Opt-out of statistics, replace with "True" to opt out
 DISABLE_STATISTICS=False

--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -184,3 +184,7 @@ networks:
   webmail:
     driver: bridge
+    ipam:
+      driver: default
+      config:
+        - subnet: "$WEBMAIL_SUBNET"
~~~


Discussion:
The rate limiter doesn't work the way you think it does.

Webmail is excluded from IP rate limits but not from account rate limits. ``AUTH_RATELIMIT_USER`` is what you are running into.
> The rate limiter doesn't work the way you think it does.

@nextgens: I'm open to be corrected here. This was only my limited understanding so far.

But the resulting behavior (a redirect loop) was unexpected for me nevertheless and really feels like a bug. Especially since I did not find any other way to recover from it.

Do you have any other suggestions then how to avoid running into this situation again?
Find out what has caused user@example.net to be rate-limited; address that.

AUTH_RATELIMIT_USER defaults to 50/day ... that means that something somewhere has attempted at least 50 different passwords against that account.

I agree that feedback on what is going on could be improved... some work has been done on it in master already (the admin container now logs exactly what is going on authentication-wise).
> Find out what has caused user@example.net to be rate-limited; address that.
> 
> `AUTH_RATELIMIT_USER` defaults to 50/day ... that means that something somewhere has attempted at least 50 different passwords against that account.

Ah, I see, so it was some other client that hit the rate limit and caused user@example.net to be blocked.
I should be able to find hints on the culprit in the log output of the containers, right?

But wouldn't it still make sense, to exempt the webmailer container from rate limiting anyway, since it has no login form but uses SSO (or at least roundcube in my case)?
Or is that what's already done in master?
> > Find out what has caused [user@example.net](mailto:user@example.net) to be rate-limited; address that.
> > `AUTH_RATELIMIT_USER` defaults to 50/day ... that means that something somewhere has attempted at least 50 different passwords against that account.
> 
> Ah, I see, so it was some other client that hit the rate limit and caused [user@example.net](mailto:user@example.net) to be blocked. I should be able to find hints on the culprit in the log output of the containers, right?
> 

Yes. It may not be trivial since there is no information about "distinctness" of passwords attempted in the logs... that's what has been fixed in master.

> But wouldn't it still make sense, to exempt the webmailer container from rate limiting anyway, since it has no login form but uses SSO (or at least roundcube in my case)? Or is that what's already done in master?

Hmm, the check is already done in SSO so if that was a new session it means your browser had the right cookies to bypass the rate-limit. Yeah I guess we can fix that; I'll send a PR.

Resolving patch:
diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py
--- a/core/admin/mailu/internal/views/auth.py
+++ b/core/admin/mailu/internal/views/auth.py
@@ -37,7 +37,7 @@ def nginx_authentication():
     is_valid_user = False
     username = response.headers.get('Auth-User', None)
     if response.headers.get("Auth-User-Exists") == "True":
-        if not is_app_token and utils.limiter.should_rate_limit_user(username, client_ip):
+        if not is_from_webmail and not is_app_token and utils.limiter.should_rate_limit_user(username, client_ip):
             # FIXME could be done before handle_authentication()
             status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit')
             response = flask.Response()


Test patch: