2646: Smarter ratelimit r=mergify[bot] a=nextgens

## What type of PR?

enhancement

## What does this PR do?

Only account for **distinct** usernames in the IP rate-limiter.

This enables to have a much tighter default as a user with a misconfigured device will now only account for a single attempt.

The goal here is to make the rate-limiter more acceptable and to avoid people disabling it altogether.

### Related issue(s)

## Prerequisites
Before we can consider review and merge, please make sure the following list is done and checked.
If an entry in not applicable, you can check it or remove it from the list.

- [ ] In case of feature or enhancement: documentation updated accordingly
- [x] Unless it's docs or a minor change: add [changelog](https://mailu.io/master/contributors/workflow.html#changelog) entry file.


Co-authored-by: Florent Daigniere <nextgens@freenetproject.org>
Co-authored-by: Alexander Graf <ghostwheel42@users.noreply.github.com>
main
bors[bot] 1 year ago committed by GitHub
commit aea7407044
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -40,9 +40,9 @@ DEFAULT_CONFIG = {
'TLS_FLAVOR': 'cert', 'TLS_FLAVOR': 'cert',
'INBOUND_TLS_ENFORCE': False, 'INBOUND_TLS_ENFORCE': False,
'DEFER_ON_TLS_ERROR': True, 'DEFER_ON_TLS_ERROR': True,
'AUTH_RATELIMIT_IP': '60/hour', 'AUTH_RATELIMIT_IP': '5/hour',
'AUTH_RATELIMIT_IP_V4_MASK': 24, 'AUTH_RATELIMIT_IP_V4_MASK': 24,
'AUTH_RATELIMIT_IP_V6_MASK': 56, 'AUTH_RATELIMIT_IP_V6_MASK': 48,
'AUTH_RATELIMIT_USER': '100/day', 'AUTH_RATELIMIT_USER': '100/day',
'AUTH_RATELIMIT_EXEMPTION': '', 'AUTH_RATELIMIT_EXEMPTION': '',
'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400, 'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400,

@ -33,8 +33,8 @@ def nginx_authentication():
for key, value in headers.items(): for key, value in headers.items():
response.headers[key] = str(value) response.headers[key] = str(value)
is_valid_user = False is_valid_user = False
username = response.headers['Auth-User']
if response.headers.get("Auth-User-Exists") == "True": if response.headers.get("Auth-User-Exists") == "True":
username = response.headers["Auth-User"]
if utils.limiter.should_rate_limit_user(username, client_ip): if utils.limiter.should_rate_limit_user(username, client_ip):
# FIXME could be done before handle_authentication() # FIXME could be done before handle_authentication()
status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit') status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit')
@ -50,7 +50,7 @@ def nginx_authentication():
elif is_valid_user: elif is_valid_user:
utils.limiter.rate_limit_user(username, client_ip) utils.limiter.rate_limit_user(username, client_ip)
elif not is_from_webmail: elif not is_from_webmail:
utils.limiter.rate_limit_ip(client_ip) utils.limiter.rate_limit_ip(client_ip, username)
return response return response
@internal.route("/auth/admin") @internal.route("/auth/admin")
@ -109,7 +109,7 @@ def basic_authentication():
utils.limiter.exempt_ip_from_ratelimits(client_ip) utils.limiter.exempt_ip_from_ratelimits(client_ip)
return response return response
# We failed check_credentials # We failed check_credentials
utils.limiter.rate_limit_user(user_email, client_ip) if user else utils.limiter.rate_limit_ip(client_ip) utils.limiter.rate_limit_user(user_email, client_ip) if user else utils.limiter.rate_limit_ip(client_ip, user_email)
response = flask.Response(status=401) response = flask.Response(status=401)
response.headers["WWW-Authenticate"] = 'Basic realm="Login Required"' response.headers["WWW-Authenticate"] = 'Basic realm="Login Required"'
return response return response

@ -52,10 +52,13 @@ class LimitWraperFactory(object):
app.logger.warn(f'Authentication attempt from {ip} has been rate-limited.') app.logger.warn(f'Authentication attempt from {ip} has been rate-limited.')
return is_rate_limited return is_rate_limited
def rate_limit_ip(self, ip): def rate_limit_ip(self, ip, username=None):
limiter = self.get_limiter(app.config["AUTH_RATELIMIT_IP"], 'auth-ip') limiter = self.get_limiter(app.config['AUTH_RATELIMIT_IP'], 'auth-ip')
client_network = utils.extract_network_from_ip(ip) client_network = utils.extract_network_from_ip(ip)
if self.is_subject_to_rate_limits(ip): if self.is_subject_to_rate_limits(ip):
if username and self.storage.get(f'dedup-{client_network}-{username}') > 0:
return
self.storage.incr(f'dedup-{client_network}-{username}', limits.parse(app.config['AUTH_RATELIMIT_IP']).GRANULARITY.seconds, True)
limiter.hit(client_network) limiter.hit(client_network)
def should_rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None): def should_rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None):

@ -47,7 +47,7 @@ def login():
flask.flash(msg, "error") flask.flash(msg, "error")
return response return response
else: else:
utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip) utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip, username)
flask.current_app.logger.warn(f'Login failed for {username} from {client_ip}.') flask.current_app.logger.warn(f'Login failed for {username} from {client_ip}.')
flask.flash('Wrong e-mail or password', 'error') flask.flash('Wrong e-mail or password', 'error')
return flask.render_template('login.html', form=form, fields=fields) return flask.render_template('login.html', form=form, fields=fields)

@ -40,11 +40,12 @@ address.
The ``WILDCARD_SENDERS`` setting is a comma delimited list of user email addresses The ``WILDCARD_SENDERS`` setting is a comma delimited list of user email addresses
that are allowed to send emails from any existing address (spoofing the sender). that are allowed to send emails from any existing address (spoofing the sender).
The ``AUTH_RATELIMIT_IP`` (default: 60/hour) holds a security setting for fighting The ``AUTH_RATELIMIT_IP`` (default: 5/hour) holds a security setting for fighting
attackers that waste server resources by trying to guess user passwords (typically attackers that attempt a password spraying attack. The value defines the limit of
using a password spraying attack). The value defines the limit of authentication authentication attempts that will be processed on **distinct** non-existing
attempts that will be processed on non-existing accounts for a specific IP subnet accounts for a specific IP subnet as defined in
(as defined in ``AUTH_RATELIMIT_IP_V4_MASK`` and ``AUTH_RATELIMIT_IP_V6_MASK`` below). ``AUTH_RATELIMIT_IP_V4_MASK`` (default: /24) and
``AUTH_RATELIMIT_IP_V6_MASK`` (default: /48).
The ``AUTH_RATELIMIT_USER`` (default: 100/day) holds a security setting for fighting The ``AUTH_RATELIMIT_USER`` (default: 100/day) holds a security setting for fighting
attackers that attempt to guess a user's password (typically using a password attackers that attempt to guess a user's password (typically using a password

@ -29,7 +29,7 @@ POSTMASTER={{ postmaster }}
# Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt) # Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt)
TLS_FLAVOR={{ tls_flavor }} TLS_FLAVOR={{ tls_flavor }}
# Authentication rate limit per IP (per /24 on ipv4 and /56 on ipv6) # Authentication rate limit per IP (per /24 on ipv4 and /48 on ipv6)
{% if auth_ratelimit_ip > '0' %} {% if auth_ratelimit_ip > '0' %}
AUTH_RATELIMIT_IP={{ auth_ratelimit_ip }}/hour AUTH_RATELIMIT_IP={{ auth_ratelimit_ip }}/hour
{% endif %} {% endif %}

@ -37,10 +37,9 @@ Or in plain english: if receivers start to classify your mail as spam, this post
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Authentication rate limit per IP for failed login attempts or non-existing accounts</label> <label>Authentication rate limit per IP for failed login attempts on unique non-existing accounts (password spraying counter-measure)</label>
<!-- Validates number input only --> <!-- Validates number input only -->
<p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_ip" <p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_ip" value="5" required > / hour
value="60" required > / hour
</p> </p>
</div> </div>

@ -0,0 +1 @@
Implement de-dupplication on rate limits. Now only attempts for distinct usernames will count as a hit.
Loading…
Cancel
Save