You've already forked Mailu
mirror of
https://github.com/Mailu/Mailu.git
synced 2025-06-17 00:07:45 +02:00
Refactor the rate limiting code
Rate limiting was already redesigned to use Python limits. This introduced some unexpected behavior, including the fact that only one criteria is supported per limiter. Docs and setup utility are updated with this in mind. Also, the code was made more generic, so limiters can be delivered for something else than authentication. Authentication-specific code was moved directly to the authentication routine.
This commit is contained in:
@ -1,23 +1,7 @@
|
|||||||
from mailu.limiter import RateLimitExceeded
|
|
||||||
|
|
||||||
from mailu import utils
|
|
||||||
from flask import current_app as app
|
|
||||||
|
|
||||||
import socket
|
|
||||||
import flask
|
import flask
|
||||||
|
|
||||||
|
|
||||||
internal = flask.Blueprint('internal', __name__, template_folder='templates')
|
internal = flask.Blueprint('internal', __name__, template_folder='templates')
|
||||||
|
|
||||||
|
|
||||||
@internal.app_errorhandler(RateLimitExceeded)
|
|
||||||
def rate_limit_handler(e):
|
|
||||||
response = flask.Response()
|
|
||||||
response.headers['Auth-Status'] = 'Authentication rate limit from one source exceeded'
|
|
||||||
response.headers['Auth-Error-Code'] = '451 4.3.2'
|
|
||||||
if int(flask.request.headers['Auth-Login-Attempt']) < 10:
|
|
||||||
response.headers['Auth-Wait'] = '3'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
from mailu.internal.views import *
|
from mailu.internal.views import *
|
||||||
|
@ -5,21 +5,31 @@ from flask import current_app as app
|
|||||||
import flask
|
import flask
|
||||||
import flask_login
|
import flask_login
|
||||||
import base64
|
import base64
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
|
||||||
@internal.route("/auth/email")
|
@internal.route("/auth/email")
|
||||||
def nginx_authentication():
|
def nginx_authentication():
|
||||||
""" Main authentication endpoint for Nginx email server
|
""" Main authentication endpoint for Nginx email server
|
||||||
"""
|
"""
|
||||||
utils.limiter.check(flask.request.headers["Client-Ip"])
|
limiter = utils.limiter.get_limiter(app.config["AUTH_RATELIMIT"], "auth-ip")
|
||||||
|
client_ip = flask.request.headers["Client-Ip"]
|
||||||
|
if not limiter.test(client_ip):
|
||||||
|
response = flask.Response()
|
||||||
|
response.headers['Auth-Status'] = 'Authentication rate limit from one source exceeded'
|
||||||
|
response.headers['Auth-Error-Code'] = '451 4.3.2'
|
||||||
|
if int(flask.request.headers['Auth-Login-Attempt']) < 10:
|
||||||
|
response.headers['Auth-Wait'] = '3'
|
||||||
|
return response
|
||||||
headers = nginx.handle_authentication(flask.request.headers)
|
headers = nginx.handle_authentication(flask.request.headers)
|
||||||
response = flask.Response()
|
response = flask.Response()
|
||||||
for key, value in headers.items():
|
for key, value in headers.items():
|
||||||
response.headers[key] = str(value)
|
response.headers[key] = str(value)
|
||||||
if ("Auth-Status" not in headers) or (headers["Auth-Status"]!="OK"):
|
if ("Auth-Status" not in headers) or (headers["Auth-Status"] != "OK"):
|
||||||
utils.limiter.hit(flask.request.headers["Client-Ip"])
|
limit_subnet = str(app.config["AUTH_RATELIMIT_SUBNET"]) != 'False'
|
||||||
|
subnet = ipaddress.ip_network(app.config["SUBNET"])
|
||||||
|
if limit_subnet or ipaddress.ip_address(client_ip) not in subnet:
|
||||||
|
limiter.hit(flask.request.headers["Client-Ip"])
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,36 +1,34 @@
|
|||||||
import limits
|
import limits
|
||||||
import limits.storage
|
import limits.storage
|
||||||
import limits.strategies
|
import limits.strategies
|
||||||
import ipaddress
|
|
||||||
|
|
||||||
class RateLimitExceeded(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class Limiter:
|
class LimitWrapper(object):
|
||||||
|
""" Wraps a limit by providing the storage, item and identifiers
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, limiter, limit, *identifiers):
|
||||||
self.storage = None
|
self.limiter = limiter
|
||||||
self.limiter = None
|
self.limit = limit
|
||||||
self.rate = None
|
self.base_identifiers = identifiers
|
||||||
self.subnet = None
|
|
||||||
self.rate_limit_subnet = True
|
def test(self, *args):
|
||||||
|
return self.limiter.test(self.limit, *(self.base_identifiers + args))
|
||||||
|
|
||||||
|
def hit(self, *args):
|
||||||
|
return self.limiter.hit(self.limit, *(self.base_identifiers + args))
|
||||||
|
|
||||||
|
def get_window_stats(self, *args):
|
||||||
|
return self.limiter.get_window_stats(self.limit, *(self.base_identifiers + args))
|
||||||
|
|
||||||
|
|
||||||
|
class LimitWraperFactory(object):
|
||||||
|
""" Global limiter, to be used as a factory
|
||||||
|
"""
|
||||||
|
|
||||||
def init_app(self, app):
|
def init_app(self, app):
|
||||||
self.storage = limits.storage.storage_from_string(app.config["RATELIMIT_STORAGE_URL"])
|
self.storage = limits.storage.storage_from_string(app.config["RATELIMIT_STORAGE_URL"])
|
||||||
self.limiter = limits.strategies.MovingWindowRateLimiter(self.storage)
|
self.limiter = limits.strategies.MovingWindowRateLimiter(self.storage)
|
||||||
self.rate = limits.parse(app.config["AUTH_RATELIMIT"])
|
|
||||||
self.rate_limit_subnet = str(app.config["AUTH_RATELIMIT_SUBNET"])!='False'
|
|
||||||
self.subnet = ipaddress.ip_network(app.config["SUBNET"])
|
|
||||||
|
|
||||||
def check(self,clientip):
|
def get_limiter(self, limit, *args):
|
||||||
# disable limits for internal requests (e.g. from webmail)?
|
return LimitWrapper(self.limiter, limits.parse(limit), *args)
|
||||||
if self.rate_limit_subnet==False and ipaddress.ip_address(clientip) in self.subnet:
|
|
||||||
return
|
|
||||||
if not self.limiter.test(self.rate,"client-ip",clientip):
|
|
||||||
raise RateLimitExceeded()
|
|
||||||
|
|
||||||
def hit(self,clientip):
|
|
||||||
# disable limits for internal requests (e.g. from webmail)?
|
|
||||||
if self.rate_limit_subnet==False and ipaddress.ip_address(clientip) in self.subnet:
|
|
||||||
return
|
|
||||||
self.limiter.hit(self.rate,"client-ip",clientip)
|
|
@ -20,7 +20,7 @@ def handle_needs_login():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Rate limiter
|
# Rate limiter
|
||||||
limiter = limiter.Limiter()
|
limiter = limiter.LimitWraperFactory()
|
||||||
|
|
||||||
# Application translation
|
# Application translation
|
||||||
babel = flask_babel.Babel()
|
babel = flask_babel.Babel()
|
||||||
|
@ -38,7 +38,7 @@ POSTMASTER=admin
|
|||||||
TLS_FLAVOR=cert
|
TLS_FLAVOR=cert
|
||||||
|
|
||||||
# Authentication rate limit (per source IP address)
|
# Authentication rate limit (per source IP address)
|
||||||
AUTH_RATELIMIT=10/minute;1000/hour
|
AUTH_RATELIMIT=10/minute
|
||||||
|
|
||||||
# Opt-out of statistics, replace with "True" to opt out
|
# Opt-out of statistics, replace with "True" to opt out
|
||||||
DISABLE_STATISTICS=False
|
DISABLE_STATISTICS=False
|
||||||
@ -68,6 +68,10 @@ ANTIVIRUS=none
|
|||||||
# Max attachment size will be 33% smaller
|
# Max attachment size will be 33% smaller
|
||||||
MESSAGE_SIZE_LIMIT=50000000
|
MESSAGE_SIZE_LIMIT=50000000
|
||||||
|
|
||||||
|
# Message rate limit for outgoing messages
|
||||||
|
# This limit is per user
|
||||||
|
MESSAGE_RATELIMIT=100/day
|
||||||
|
|
||||||
# Networks granted relay permissions
|
# Networks granted relay permissions
|
||||||
# Use this with care, all hosts in this networks will be able to send mail without authentication!
|
# Use this with care, all hosts in this networks will be able to send mail without authentication!
|
||||||
RELAYNETS=
|
RELAYNETS=
|
||||||
|
@ -46,7 +46,6 @@ rules does also apply to auth requests coming from ``SUBNET``, especially for th
|
|||||||
If you disable this, ensure that the rate limit on the webmail is enforced in a different
|
If you disable this, ensure that the rate limit on the webmail is enforced in a different
|
||||||
way (e.g. roundcube plug-in), otherwise an attacker can simply bypass the limit using webmail.
|
way (e.g. roundcube plug-in), otherwise an attacker can simply bypass the limit using webmail.
|
||||||
|
|
||||||
|
|
||||||
The ``TLS_FLAVOR`` sets how Mailu handles TLS connections. Setting this value to
|
The ``TLS_FLAVOR`` sets how Mailu handles TLS connections. Setting this value to
|
||||||
``notls`` will cause Mailu not to server any web content! More on :ref:`tls_flavor`.
|
``notls`` will cause Mailu not to server any web content! More on :ref:`tls_flavor`.
|
||||||
|
|
||||||
@ -57,6 +56,10 @@ The ``MESSAGE_SIZE_LIMIT`` is the maximum size of a single email. It should not
|
|||||||
be too low to avoid dropping legitimate emails and should not be too high to
|
be too low to avoid dropping legitimate emails and should not be too high to
|
||||||
avoid filling the disks with large junk emails.
|
avoid filling the disks with large junk emails.
|
||||||
|
|
||||||
|
The ``MESSAGE_RATELIMIT`` is the limit of messages a single user can send. This is
|
||||||
|
meant to fight outbound spam in case of compromised or malicious account on the
|
||||||
|
server.
|
||||||
|
|
||||||
The ``RELAYNETS`` are network addresses for which mail is relayed for free with
|
The ``RELAYNETS`` are network addresses for which mail is relayed for free with
|
||||||
no authentication required. This should be used with great care. If you want other
|
no authentication required. This should be used with great care. If you want other
|
||||||
Docker services' outbound mail to be relayed, you can set this to ``172.16.0.0/12``
|
Docker services' outbound mail to be relayed, you can set this to ``172.16.0.0/12``
|
||||||
|
@ -30,8 +30,8 @@ POSTMASTER={{ postmaster }}
|
|||||||
TLS_FLAVOR={{ tls_flavor }}
|
TLS_FLAVOR={{ tls_flavor }}
|
||||||
|
|
||||||
# Authentication rate limit (per source IP address)
|
# Authentication rate limit (per source IP address)
|
||||||
{% if auth_ratelimit_pm > '0' and auth_ratelimit_ph > '0' %}
|
{% if auth_ratelimit_pm > '0' %}
|
||||||
AUTH_RATELIMIT={{ auth_ratelimit_pm }}/minute;{{ auth_ratelimit_ph }}/hour
|
AUTH_RATELIMIT={{ auth_ratelimit_pm }}/minute
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
# Opt-out of statistics, replace with "True" to opt out
|
# Opt-out of statistics, replace with "True" to opt out
|
||||||
|
@ -47,11 +47,10 @@ Or in plain english: if receivers start to classify your mail as spam, this post
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Authentication rate limit (per source IP address)</label>
|
<label>Authentication rate limit (per source IP address)</label>
|
||||||
<!-- Validates number input only -->
|
<!-- Validates number input only -->
|
||||||
<p><input class="form-control" style="width: 7%; display: inline;" type="number" name="auth_ratelimit_pm"
|
<p><input class="form-control" style="width: 7%; display: inline;" type="number" name="auth_ratelimit_pm"
|
||||||
value="10" required >/minute;
|
value="10" required > / minute
|
||||||
<input class="form-control" style="width: 7%; display: inline;;" type="number" name="auth_ratelimit_ph"
|
</p>
|
||||||
value="1000" required >/hour</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-check form-check-inline">
|
<div class="form-check form-check-inline">
|
||||||
|
Reference in New Issue
Block a user