From 5714b4f4b0326ee808d0fb4242a1d92ac913d4ae Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 6 Nov 2021 10:05:52 +0100 Subject: [PATCH 1/7] introduce MESSAGE_RATELIMIT_EXEMPTION --- core/admin/mailu/configuration.py | 1 + core/admin/mailu/internal/views/postfix.py | 2 ++ docs/configuration.rst | 8 +++++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 9829f798..d395073d 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -54,6 +54,7 @@ DEFAULT_CONFIG = { 'DKIM_PATH': '/dkim/{domain}.{selector}.key', 'DEFAULT_QUOTA': 1000000000, 'MESSAGE_RATELIMIT': '200/day', + 'MESSAGE_RATELIMIT_EXEMPTION': '', 'RECIPIENT_DELIMITER': '', # Web settings 'SITENAME': 'Mailu', diff --git a/core/admin/mailu/internal/views/postfix.py b/core/admin/mailu/internal/views/postfix.py index ab965967..2664f968 100644 --- a/core/admin/mailu/internal/views/postfix.py +++ b/core/admin/mailu/internal/views/postfix.py @@ -149,6 +149,8 @@ def postfix_sender_login(sender): def postfix_sender_rate(sender): """ Rate limit outbound emails per sender login """ + if sender in [s for s in flask.current_app.config.get('MESSAGE_RATELIMIT_EXEMPTION', '').lower().replace(' ', '').split(',') if s]: + flask.abort(404) user = models.User.get(sender) or flask.abort(404) return flask.abort(404) if user.sender_limiter.hit() else flask.jsonify("450 4.2.1 You are sending too many emails too fast.") diff --git a/docs/configuration.rst b/docs/configuration.rst index fa574415..39680fbd 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -69,9 +69,11 @@ 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 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 ``MESSAGE_RATELIMIT`` (default: 200/day) is the maximum number of messages +a single user can send. ``MESSAGE_RATELIMIT_EXEMPTION`` contains a comma delimited +list of user email addresses that are exempted from any restriction. Those +settings are meant to reduce outbound spam in case of compromised or malicious +account on the server. The ``RELAYNETS`` (default: unset) is a comma delimited list of network addresses for which mail is relayed for with no authentication required. This should be From 6c6b0b161caa31c2ba4ce45e715d0324ebf22e41 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 6 Nov 2021 10:45:59 +0100 Subject: [PATCH 2/7] Set the right flags on the rate_limit cookie --- core/admin/mailu/sso/views/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index fbee52a7..c11c588a 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -38,7 +38,7 @@ def login(): flask.session.regenerate() flask_login.login_user(user) response = flask.redirect(destination) - response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login')) + response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=True, httponly=True) flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.') return response else: From bbef4bee2763011af11a14568c4d85c54a73557e Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sun, 7 Nov 2021 12:20:31 +0100 Subject: [PATCH 3/7] Don't return any key for relayed domains We may want to revisit this (ARC signing)... but in the meantime it saves from a scary message in rspamd signing failure: cannot request data from the vault url: /internal/rspamd/vault/v1/dkim/ ... --- core/admin/mailu/internal/views/rspamd.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/admin/mailu/internal/views/rspamd.py b/core/admin/mailu/internal/views/rspamd.py index 8551eb8f..123ec4e2 100644 --- a/core/admin/mailu/internal/views/rspamd.py +++ b/core/admin/mailu/internal/views/rspamd.py @@ -14,6 +14,11 @@ def vault_error(*messages, status=404): @internal.route("/rspamd/vault/v1/dkim/", methods=['GET']) def rspamd_dkim_key(domain_name): + models.Relay.query.get(domain_name) and return flask.jsonify({ + 'data': { + 'selectors': [] + } + }) domain = models.Domain.query.get(domain_name) or flask.abort(vault_error('unknown domain')) key = domain.dkim_key or flask.abort(vault_error('no dkim key', status=400)) return flask.jsonify({ From dc6e970a7f668a9030aff44100682b3a17dc6346 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sun, 7 Nov 2021 12:41:29 +0100 Subject: [PATCH 4/7] handle HTTP too --- core/admin/mailu/sso/views/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index c11c588a..831949e7 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -38,7 +38,7 @@ def login(): flask.session.regenerate() flask_login.login_user(user) response = flask.redirect(destination) - response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=True, httponly=True) + response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=app.config['SESSION_COOKIE_SECURE'], httponly=True) flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.') return response else: From b68033eb43fd249a9092df3f536ffa551de156a5 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Mon, 8 Nov 2021 09:23:24 +0100 Subject: [PATCH 5/7] only parse it once --- core/admin/mailu/configuration.py | 1 + core/admin/mailu/internal/views/postfix.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index d395073d..a997a8c7 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -156,6 +156,7 @@ class ConfigManager(dict): self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME'])) hostnames = [host.strip() for host in self.config['HOSTNAMES'].split(',')] self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(cidr, False) for cidr in (cidr.strip() for cidr in self.config['AUTH_RATELIMIT_EXEMPTION'].split(',')) if cidr) + self.config['MESSAGE_RATELIMIT_EXEMPTION'] = set([s for s in self.config['MESSAGE_RATELIMIT_EXEMPTION'].lower().replace(' ', '').split(',') if s]) self.config['HOSTNAMES'] = ','.join(hostnames) self.config['HOSTNAME'] = hostnames[0] # update the app config itself diff --git a/core/admin/mailu/internal/views/postfix.py b/core/admin/mailu/internal/views/postfix.py index 2664f968..ed951943 100644 --- a/core/admin/mailu/internal/views/postfix.py +++ b/core/admin/mailu/internal/views/postfix.py @@ -149,7 +149,7 @@ def postfix_sender_login(sender): def postfix_sender_rate(sender): """ Rate limit outbound emails per sender login """ - if sender in [s for s in flask.current_app.config.get('MESSAGE_RATELIMIT_EXEMPTION', '').lower().replace(' ', '').split(',') if s]: + if sender in flask.current_app.config['MESSAGE_RATELIMIT_EXEMPTION']: flask.abort(404) user = models.User.get(sender) or flask.abort(404) return flask.abort(404) if user.sender_limiter.hit() else flask.jsonify("450 4.2.1 You are sending too many emails too fast.") From 6bf1a178b94bbfe17da0f0073ab4553ada399c01 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Mon, 8 Nov 2021 09:34:02 +0100 Subject: [PATCH 6/7] Go with ghostwheel42's suggestion --- core/admin/mailu/internal/views/rspamd.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/core/admin/mailu/internal/views/rspamd.py b/core/admin/mailu/internal/views/rspamd.py index 123ec4e2..458dbb81 100644 --- a/core/admin/mailu/internal/views/rspamd.py +++ b/core/admin/mailu/internal/views/rspamd.py @@ -14,22 +14,14 @@ def vault_error(*messages, status=404): @internal.route("/rspamd/vault/v1/dkim/", methods=['GET']) def rspamd_dkim_key(domain_name): - models.Relay.query.get(domain_name) and return flask.jsonify({ - 'data': { - 'selectors': [] - } - }) - domain = models.Domain.query.get(domain_name) or flask.abort(vault_error('unknown domain')) - key = domain.dkim_key or flask.abort(vault_error('no dkim key', status=400)) - return flask.jsonify({ - 'data': { - 'selectors': [ + selectors = [] + if domain := models.Domain.query.get(domain_name): + if key := domain.dkim_key: + selectors.append( { 'domain' : domain.name, 'key' : key.decode('utf8'), 'selector': flask.current_app.config.get('DKIM_SELECTOR', 'dkim'), } - ] - } - }) - + ) + return flask.jsonify({'data': {'selectors': selectors}}) From c48e00ee2664a8d74d9797706d465029cd358605 Mon Sep 17 00:00:00 2001 From: Till Skrodzki Date: Tue, 9 Nov 2021 12:22:53 +0100 Subject: [PATCH 7/7] Do not call .split() on RELAYNETS if not specified --- core/postfix/conf/main.cf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/postfix/conf/main.cf b/core/postfix/conf/main.cf index 5fffc330..8ac646da 100644 --- a/core/postfix/conf/main.cf +++ b/core/postfix/conf/main.cf @@ -17,7 +17,7 @@ queue_directory = /queue message_size_limit = {{ MESSAGE_SIZE_LIMIT }} # Relayed networks -mynetworks = 127.0.0.1/32 [::1]/128 {{ SUBNET }} {{ RELAYNETS.split(",") | join(' ') }} +mynetworks = 127.0.0.1/32 [::1]/128 {{ SUBNET }} {% if RELAYNETS %}{{ RELAYNETS.split(",") | join(' ') }}{% endif %} # Empty alias list to override the configuration variable and disable NIS alias_maps =