[Dovecot][Netfilter] Report failed logins from mailcowauth.php, anchor default rules

This commit is contained in:
FreddleSpl0it committed 2026-11-02 14:11:56 +01:00
1 parent 06cbdc4409
commit 5c762d9949
10 files changed
+190 -114

No files matched your search

@@ -7,9 +7,6 @@ class BanManager:
and the allowlist. Every firewall change runs under lock, which also guards the other firewall work of netfilter and the allowlist. Every firewall change runs under lock, which also guards the other firewall work of netfilter
(chains, SNAT), because the backends must not run in parallel.""" (chains, SNAT), because the backends must not run in parallel."""
# Seconds failed logins of an address are not counted after the auth backend was unavailable for it
EXCUSE_TIME = 60
def __init__(self, r, tables, options, logger, lock, clock=time.time): def __init__(self, r, tables, options, logger, lock, clock=time.time):
self.r = r self.r = r
self.tables = tables self.tables = tables
@@ -21,16 +18,6 @@ class BanManager:
self.banned = set() self.banned = set()
self.denied = {} self.denied = {}
self.allowlist = [] self.allowlist = []
self.excused = {}
def excuse(self, ip):
"""The auth backend could not check a login of ip: its failed logins are not counted for EXCUSE_TIME"""
with self.lock:
now = self.clock()
if self.excused.get(ip, 0) <= now:
self.logger.logWarn('Auth backend unavailable for %s, its failed logins of the next %d seconds are not counted' % (
ip, self.EXCUSE_TIME))
self.excused[ip] = now + self.EXCUSE_TIME
def attempt(self, ip): def attempt(self, ip):
"""Counts a failed attempt of ip and bans its network after max_attempts within retry_window""" """Counts a failed attempt of ip and bans its network after max_attempts within retry_window"""
@@ -43,9 +30,6 @@ class BanManager:
if allowed is not None: if allowed is not None:
self.logger.logInfo('Address %s is allowlisted by rule %s' % (ip, allowed)) self.logger.logInfo('Address %s is allowlisted by rule %s' % (ip, allowed))
return return
if self.excused.get(ip, 0) > now:
self.logger.logInfo('Not counting the failed login of %s, the auth backend was unavailable' % ip)
return
if net in self.banned: if net in self.banned:
# log lines of connections from before the ban # log lines of connections from before the ban
return return
@@ -208,8 +192,6 @@ class BanManager:
for net in [net for net, counter in self.counters.items() for net in [net for net, counter in self.counters.items()
if net not in self.banned and counter['ban_counter'] == 0 and now - counter['last_attempt'] > window]: if net not in self.banned and counter['ban_counter'] == 0 and now - counter['last_attempt'] > window]:
del self.counters[net] del self.counters[net]
for ip in [ip for ip, until in self.excused.items() if until <= now]:
del self.excused[ip]
def apply(self, nets): def apply(self, nets):
for net in self.tables.ban(nets): for net in self.tables.ban(nets):
@@ -109,12 +109,6 @@ class Netfilter:
def handle_line(self, line): def handle_line(self, line):
# a broken line must not end the watcher # a broken line must not end the watcher
try: try:
excused = Regexes.temporary_failure(line)
if excused is not None:
ip = Regexes.parse_ip(excused)
if self.external(ip):
self.bans.excuse(ip)
return
result = self.regexes.match(line) result = self.regexes.match(line)
if result is None: if result is None:
return return
+67 -16
View File
@@ -6,20 +6,41 @@ import re
class Regexes: class Regexes:
"""Rules for log lines (Redis F2B_REGEX): rule id => regex whose first group captures the client address""" """Rules for log lines (Redis F2B_REGEX): rule id => regex whose first group captures the client address"""
# Anchored to the line start: text clients control in other lines (login names, SMTP commands) must not match.
# Failed IMAP, POP3, Sieve and SMTP logins are reported by mailcowauth.php (rule 3), not by Dovecot or Postfix.
DEFAULTS = { DEFAULTS = {
1: r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)', 1: r'^mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)$',
2: r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)', 2: r'^Rspamd UI: Invalid password by ([0-9a-f\.:]+)$',
3: r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+', 3: r'^MAILCOWAUTH: Login failed for user .+ with service \w* from IP ([0-9a-f\.:]+)$',
4: r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+', 4: r'^warning: non-SMTP command from [^\[\]\s]*\[([0-9a-f\.:]+)\]:.+',
5: r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+', 5: r'^NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
6: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)', 6: r'^(?:\[\d+\]: )?SOGo\w* Login from \'(?:[0-9a-f\.:]+, )*([0-9a-f\.:]+)\' for user .+ might not have worked',
7: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)', 7: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
8: r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
9: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
} }
# Dovecot could not ask the auth backend (identity provider down, mailcowauth.php unreachable). Postfix logs # Former default rules (with any rule id) => rule id of their replacement, None: the rule is removed.
# such a temporary failure like a wrong password, so failed logins of the address are not counted for a while. # Rules the admin changed are kept.
TEMPORARY_FAILURE = re.compile(r'lua\([^,]+,([0-9a-f\.:]+)(?:,<[^>]+>)?\): passdb-lua: Upstream (?:unavailable|unreachable)') FORMER_DEFAULTS = {
r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)': 1,
r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)': 2,
r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed': 3,
r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+': 3,
r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+': 4,
r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+': 5,
r"SOGo.+ Login from '([0-9a-f\.:]+)' for user .+ might not have worked": 6,
r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked': 6,
r'-login: Disconnected \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),': None,
r'-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),': None,
r'-login: Aborted login \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'-login: Aborted login \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'auth: static\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': None,
r'auth: \w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': None,
r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': None,
r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)': None,
}
# Lines of the web UI are not sanitized like syslog lines: a newline in a login name must not hide the address
CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]')
def __init__(self, logger): def __init__(self, logger):
self.logger = logger self.logger = logger
@@ -42,6 +63,10 @@ class Regexes:
if not isinstance(stored, dict): if not isinstance(stored, dict):
self.logger.logWarn('F2B_REGEX is no JSON object, keeping the current rules') self.logger.logWarn('F2B_REGEX is no JSON object, keeping the current rules')
return return
migrated = self.migrate(stored)
if migrated != stored:
self.store_migrated(r)
stored = migrated
rules = [] rules = []
for rule_id, regex in sorted(stored.items(), key=lambda item: self.sort_key(item[0])): for rule_id, regex in sorted(stored.items(), key=lambda item: self.sort_key(item[0])):
@@ -54,6 +79,7 @@ class Regexes:
def match(self, line): def match(self, line):
"""(rule id, captured address) of the first rule that matches the line, None if no rule matches""" """(rule id, captured address) of the first rule that matches the line, None if no rule matches"""
line = self.CONTROL_CHARS.sub('_', line)
for rule_id, regex in self.rules: for rule_id, regex in self.rules:
result = regex.search(line) result = regex.search(line)
if result and result.lastindex and result.group(1): if result and result.lastindex and result.group(1):
@@ -61,10 +87,35 @@ class Regexes:
return None return None
@classmethod @classmethod
def temporary_failure(cls, line): def migrate(cls, stored):
"""Address of a login that failed because the auth backend was unavailable, None for other lines""" """The stored rules with former default rules replaced by the current ones (or removed)"""
result = cls.TEMPORARY_FAILURE.search(line) migrated = {}
return result.group(1) if result else None for rule_id, regex in stored.items():
if isinstance(regex, str) and regex in cls.FORMER_DEFAULTS:
replacement = cls.FORMER_DEFAULTS[regex]
if replacement is None:
continue
regex = cls.DEFAULTS[replacement]
migrated[rule_id] = regex
return migrated
def store_migrated(self, r):
# WATCH keeps a save of the UI between reading and writing
def replace(pipe):
current = json.loads(pipe.get('F2B_REGEX') or '{}')
if not isinstance(current, dict):
return
migrated = self.migrate(current)
if migrated == current:
return
pipe.multi()
pipe.set('F2B_REGEX', json.dumps(migrated, ensure_ascii=False))
try:
r.transaction(replace, 'F2B_REGEX')
self.logger.logInfo('Replaced former default rules in F2B_REGEX')
except Exception as ex:
self.logger.logWarn('Could not replace former default rules in F2B_REGEX: %s' % ex)
@staticmethod @staticmethod
def parse_ip(text): def parse_ip(text):
+10 -1
View File
@@ -68,6 +68,8 @@ require_once 'functions.acl.inc.php';
$isSOGoRequest = $post['real_rip'] == getenv('IPV4_NETWORK') . '.248'; $isSOGoRequest = $post['real_rip'] == getenv('IPV4_NETWORK') . '.248';
// real_rip is the address that connected to Dovecot (for proxied connections the proxy), client_ip the client
$client_ip = filter_var($post['client_ip'] ?? '', FILTER_VALIDATE_IP) ? $post['client_ip'] : $post['real_rip'];
$result = false; $result = false;
if ($isSOGoRequest) { if ($isSOGoRequest) {
// This is a SOGo Auth request. First check for SSO password. // This is a SOGo Auth request. First check for SSO password.
@@ -121,7 +123,14 @@ if ($result) {
http_response_code(200); // OK http_response_code(200); // OK
$return['success'] = true; $return['success'] = true;
} else { } else {
error_log("MAILCOWAUTH: Login failed for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']); // Same line for the log and netfilter, which counts it for IMAP, POP3, Sieve and SMTP (Postfix asks Dovecot);
// SOGo logins have their own rule
$failed = "MAILCOWAUTH: Login failed for user " . $post['username'] . " with service " .
preg_replace('/[^a-z0-9]/', '', strtolower((string)$service)) . " from IP " . $client_ip;
error_log($failed);
if (!$isSOGoRequest) {
$redis->publish("F2B_CHANNEL", $failed);
}
http_response_code(401); // Unauthorized http_response_code(401); // Unauthorized
} }
+3 -4
View File
@@ -1,8 +1,4 @@
function auth_password_verify(request, password) function auth_password_verify(request, password)
if request.domain == nil then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "No such user"
end
local json = require "cjson" local json = require "cjson"
local ltn12 = require "ltn12" local ltn12 = require "ltn12"
local https = require "ssl.https" local https = require "ssl.https"
@@ -11,7 +7,10 @@ function auth_password_verify(request, password)
local req = { local req = {
username = request.user, username = request.user,
password = password, password = password,
-- address that connected to Dovecot, for proxied connections the proxy
real_rip = request.real_rip, real_rip = request.real_rip,
-- address of the client, also for proxied connections
client_ip = request.rip,
service = request.service service = request.service
} }
local req_json = json.encode(req) local req_json = json.encode(req)
+2 -8
View File
@@ -51,13 +51,6 @@ recipient_delimiter = +
auth_master_user_separator = * auth_master_user_separator = *
mail_shared_explicit_inbox = yes mail_shared_explicit_inbox = yes
mail_prefetch_count = 30 mail_prefetch_count = 30
passdb {
driver = lua
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
result_success = return-ok
result_failure = continue
result_internalfail = continue
}
# try a master passwd # try a master passwd
passdb { passdb {
driver = passwd-file driver = passwd-file
@@ -67,8 +60,9 @@ passdb {
} }
# check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail # check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail
# a return of the following passdb is mandatory # a return of the following passdb is mandatory
# each check asks mailcowauth.php, which reports a failed login to netfilter once
# cache_key with password: the lua default (%u) would let a temporary failure fall back to the negative entry # cache_key with password: the lua default (%u) would let a temporary failure fall back to the negative entry
# of a passdb lookup (master logins) and log "unknown user", which netfilter counts as failed login # of a passdb lookup (master logins) and reject the login as unknown user
passdb { passdb {
driver = lua driver = lua
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
+1 -1
View File
@@ -515,7 +515,7 @@ services:
- acme - acme
netfilter-mailcow: netfilter-mailcow:
image: ghcr.io/mailcow/netfilter:1.66 image: ghcr.io/mailcow/netfilter:1.67
stop_grace_period: 30s stop_grace_period: 30s
restart: always restart: always
privileged: true privileged: true
-19
View File
@@ -188,25 +188,6 @@ class BanManagerTest(unittest.TestCase):
self.bans.purge() self.bans.purge()
self.assertEqual(self.bans.counters, {}) self.assertEqual(self.bans.counters, {})
def test_excused_address_is_not_counted(self):
ip = ipaddress.ip_address('203.0.113.1')
self.bans.excuse(ip)
self.bans.excuse(ip)
self.attempts('203.0.113.1', 5)
self.assertEqual(self.active(), {})
self.assertEqual(len([message for message in self.logger.messages('warn') if 'Auth backend unavailable' in message]), 1)
# other addresses are counted
self.attempts('203.0.113.2', 3)
self.assertIn('203.0.113.2/32', self.active())
def test_excuse_ends(self):
self.bans.excuse(ipaddress.ip_address('203.0.113.1'))
self.clock.now += BanManager.EXCUSE_TIME + 1
self.bans.purge()
self.assertEqual(self.bans.excused, {})
self.attempts('203.0.113.1', 3)
self.assertIn('203.0.113.1/32', self.active())
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+23 -24
View File
@@ -25,8 +25,8 @@ class NetfilterTest(unittest.TestCase):
netfilter.regexes.refresh(self.r) netfilter.regexes.refresh(self.r)
return netfilter return netfilter
def sasl(self, address): def failed(self, address):
return 'warning: unknown[%s]: SASL LOGIN authentication failed: UGFzc3dvcmQ6' % address return 'MAILCOWAUTH: Login failed for user a@example.org with service imap from IP %s' % address
def test_setup_restores_bans_and_resets_the_firewall(self): def test_setup_restores_bans_and_resets_the_firewall(self):
self.r.hset('F2B_ACTIVE_BANS', '1.2.3.4/32', '9999999999') self.r.hset('F2B_ACTIVE_BANS', '1.2.3.4/32', '9999999999')
@@ -49,8 +49,8 @@ class NetfilterTest(unittest.TestCase):
def test_shutdown_keeps_the_active_bans(self): def test_shutdown_keeps_the_active_bans(self):
netfilter = self.create() netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
self.r.hset('F2B_PERM_BANS', '198.51.100.1', '1') self.r.hset('F2B_PERM_BANS', '198.51.100.1', '1')
netfilter.shutdown() netfilter.shutdown()
self.assertEqual(self.tables.rules, set()) self.assertEqual(self.tables.rules, set())
@@ -59,52 +59,51 @@ class NetfilterTest(unittest.TestCase):
def test_handle_line_bans(self): def test_handle_line_bans(self):
netfilter = self.create() netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
self.assertEqual(self.tables.rules, {'1.2.3.4/32'}) self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
self.assertIn('1.2.3.4 matched rule id 3 (%s)' % self.sasl('1.2.3.4'), self.logger.messages('warn')) self.assertIn('1.2.3.4 matched rule id 3 (%s)' % self.failed('1.2.3.4'), self.logger.messages('warn'))
def test_handle_line_with_trailing_colon(self): def test_handle_line_with_trailing_colon(self):
netfilter = self.create() netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4:')) netfilter.handle_line(self.failed('1.2.3.4:'))
netfilter.handle_line(self.sasl('1.2.3.4:')) netfilter.handle_line(self.failed('1.2.3.4:'))
self.assertEqual(self.tables.rules, {'1.2.3.4/32'}) self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
def test_handle_line_ignores_private_and_invalid_addresses(self): def test_handle_line_ignores_private_and_invalid_addresses(self):
netfilter = self.create() netfilter = self.create()
for _ in range(3): for _ in range(3):
netfilter.handle_line(self.sasl('192.168.1.1')) netfilter.handle_line(self.failed('192.168.1.1'))
netfilter.handle_line(self.sasl('::1')) netfilter.handle_line(self.failed('::1'))
netfilter.handle_line(self.sasl('1.2.3')) netfilter.handle_line(self.failed('1.2.3'))
self.assertEqual(self.tables.rules, set()) self.assertEqual(self.tables.rules, set())
self.assertTrue(any('invalid address 1.2.3' in message for message in self.logger.messages('warn'))) self.assertTrue(any('invalid address 1.2.3' in message for message in self.logger.messages('warn')))
def test_handle_line_with_replaced_characters(self): def test_handle_line_with_replaced_characters(self):
netfilter = self.create() netfilter = self.create()
line = b'warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: \xff\xfe'.decode('utf-8', 'replace') line = b'MAILCOWAUTH: Login failed for user \xff\xfe with service imap from IP 1.2.3.4'.decode('utf-8', 'replace')
netfilter.handle_line(line) netfilter.handle_line(line)
netfilter.handle_line(line) netfilter.handle_line(line)
self.assertEqual(self.tables.rules, {'1.2.3.4/32'}) self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
def test_unavailable_auth_backend_is_no_failed_login(self): def test_postfix_and_dovecot_lines_are_no_failed_logins(self):
# Dovecot logs the temporary failure, Postfix then the failed SASL login like for a wrong password # mailcowauth.php reports failed logins, not when the auth backend was unavailable
netfilter = self.create() netfilter = self.create()
netfilter.handle_line('auth-worker(1): Error: conn unix:auth-worker (pid=2,uid=401): auth-worker<4>: '
'lua(a@example.org,1.2.3.4): passdb-lua: Upstream unavailable')
for _ in range(3): for _ in range(3):
netfilter.handle_line('warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org') netfilter.handle_line('warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org')
netfilter.handle_line('imap-login: Disconnected: Connection closed (auth failed, 1 attempts in 2 secs): user=<a@example.org>, '
'method=PLAIN, rip=1.2.3.4, lip=172.22.1.250')
self.assertEqual(self.tables.rules, set()) self.assertEqual(self.tables.rules, set())
self.assertNotIn('1.2.3.4/32', self.r.hgetall('F2B_ACTIVE_BANS'))
def test_handle_line_survives_errors(self): def test_handle_line_survives_errors(self):
netfilter = self.create() netfilter = self.create()
with mock.patch.object(netfilter.bans, 'attempt', side_effect=RuntimeError('boom')): with mock.patch.object(netfilter.bans, 'attempt', side_effect=RuntimeError('boom')):
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
self.assertTrue(any('boom' in message for message in self.logger.messages('warn'))) self.assertTrue(any('boom' in message for message in self.logger.messages('warn')))
def test_watch_reconnects_after_errors(self): def test_watch_reconnects_after_errors(self):
netfilter = self.create() netfilter = self.create()
self.r.messages.extend([self.sasl('1.2.3.4'), ConnectionError('gone'), self.sasl('1.2.3.4')]) self.r.messages.extend([self.failed('1.2.3.4'), ConnectionError('gone'), self.failed('1.2.3.4')])
netfilter.loop('watch', netfilter.watch, 0) netfilter.loop('watch', netfilter.watch, 0)
self.assertEqual(self.tables.rules, {'1.2.3.4/32'}) self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
self.assertEqual(len(self.r.pubsubs), 2) self.assertEqual(len(self.r.pubsubs), 2)
@@ -126,8 +125,8 @@ class NetfilterTest(unittest.TestCase):
def test_recreated_chain_gets_isolation_and_bans_again(self): def test_recreated_chain_gets_isolation_and_bans_again(self):
netfilter = self.create() netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
self.tables.rules = set() self.tables.rules = set()
self.tables.calls = [] self.tables.calls = []
self.tables.recreate = True self.tables.recreate = True
@@ -151,8 +150,8 @@ class NetfilterTest(unittest.TestCase):
self.r.hset('F2B_BLACKLIST', '198.51.100.1', 1) self.r.hset('F2B_BLACKLIST', '198.51.100.1', 1)
netfilter.update_lists() netfilter.update_lists()
self.assertEqual(self.tables.rules, {'198.51.100.1/32'}) self.assertEqual(self.tables.rules, {'198.51.100.1/32'})
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
self.assertNotIn('1.2.3.4/32', self.tables.rules) self.assertNotIn('1.2.3.4/32', self.tables.rules)
def test_resolve_keeps_addresses_on_dns_errors(self): def test_resolve_keeps_addresses_on_dns_errors(self):
+84 -17
View File
@@ -10,14 +10,33 @@ class RegexesTest(unittest.TestCase):
LINES = { LINES = {
'1': ('mailcow UI: Invalid password for admin by 203.0.113.5', '203.0.113.5'), '1': ('mailcow UI: Invalid password for admin by 203.0.113.5', '203.0.113.5'),
'2': ('Rspamd UI: Invalid password by 2001:db8::5', '2001:db8::5'), '2': ('Rspamd UI: Invalid password by 2001:db8::5', '2001:db8::5'),
'3': ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: UGFzc3dvcmQ6', '203.0.113.6'), '3': ('MAILCOWAUTH: Login failed for user user@example.org with service smtp from IP 203.0.113.6', '203.0.113.6'),
'4': ('warning: non-SMTP command from unknown[203.0.113.7]: GET / HTTP/1.1', '203.0.113.7'), '4': ('warning: non-SMTP command from unknown[203.0.113.7]: GET _ HTTP_1.1', '203.0.113.7'),
'5': ('NOQUEUE: reject: RCPT from [203.0.113.8]: 503 5.5.0 <a@example.org>: Protocol error; proto=ESMTP', '203.0.113.8'), '5': ('NOQUEUE: reject: RCPT from [203.0.113.8]:52516: 550 5.5.1 Protocol error; from=<a@example.org> to=<b@example.org>',
'6': ('auth: lua(user@example.org,203.0.113.9,<abcdef>): Password mismatch (SHA1 of given password: 0123ab)', '203.0.113.9'), '203.0.113.8'),
'7': ('auth: lua(nobody@example.org,203.0.113.10,<abcdef>): unknown user (SHA1 of given password: 0123ab)', '203.0.113.10'), '6': ("[6714]: SOGoRootPage Login from '203.0.113.11' for user 'nobody@example.org' might not have worked - "
'8': ("SOGo[123]: Login from '203.0.113.11' for user nobody might not have worked", '203.0.113.11'), "password policy: 65535 grace: -1 expire: -1 bound: 0", '203.0.113.11'),
'9': ('203.0.113.12 "GET /SOGo/so/ HTTP/1.1" 403 123 "-"', '203.0.113.12'), '7': ('203.0.113.12 "GET /SOGo/so/ HTTP/1.1" 403 123 "-"', '203.0.113.12'),
} }
# Defaults of 2025-02 to 2026-10 and of 2022-11 to 2025-02
FORMER = {
'1': r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)',
'2': r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)',
'3': r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+',
'4': r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+',
'5': r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
'6': r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)',
'7': r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)',
'8': r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
'9': r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
}
OLDER = dict(FORMER, **{
'6': r'-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),',
'7': r'-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+',
'8': r'-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+',
'9': r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
'10': r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
})
def setUp(self): def setUp(self):
self.r = FakeRedis() self.r = FakeRedis()
@@ -30,10 +49,65 @@ class RegexesTest(unittest.TestCase):
for rule_id, (line, address) in self.LINES.items(): for rule_id, (line, address) in self.LINES.items():
self.assertEqual(self.regexes.match(line), (rule_id, address), line) self.assertEqual(self.regexes.match(line), (rule_id, address), line)
def test_lost_connection_to_auth_server_is_no_attempt(self): def test_postfix_and_dovecot_lines_are_no_attempts(self):
# mailcowauth.php reports the failed login once, also when Postfix asked Dovecot
self.regexes.refresh(self.r) self.regexes.refresh(self.r)
line = 'warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: Connection lost to authentication server' for line in ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org',
self.assertIsNone(self.regexes.match(line)) 'auth-worker(37358): conn unix:auth-worker (pid=577,uid=401): auth-worker<25>: '
'lua(a@example.org,203.0.113.6,<j4rbQeReAqwqAPggBBcAAAAFAAEAdgES>): Password mismatch (SHA1 of given password: 6426a0)',
'imap-login: Disconnected: Connection closed (auth failed, 1 attempts in 2 secs): user=<a@example.org>, '
'method=PLAIN, rip=203.0.113.6, lip=172.22.1.250'):
self.assertIsNone(self.regexes.match(line), line)
def test_text_of_clients_does_not_match(self):
# Login names, SMTP commands and addresses of other lines must not ban a foreign address
self.regexes.refresh(self.r)
self.assertEqual(self.regexes.match('warning: non-SMTP command from unknown[198.51.100.7]: '
'mailcow UI: Invalid password for x by 203.0.113.1'), ('4', '198.51.100.7'))
self.assertEqual(self.regexes.match('warning: non-SMTP command from unknown[198.51.100.7]: x [203.0.113.1]: y'),
('4', '198.51.100.7'))
self.assertIsNone(self.regexes.match('NOQUEUE: reject: RCPT from unknown[198.51.100.7]: 554 5.7.1 Relay access denied; '
'from=<NOQUEUE: reject: RCPT from [203.0.113.1] Protocol error x>'))
self.assertIsNone(self.regexes.match('improper command pipelining after EHLO from unknown[198.51.100.7]: '
'MAILCOWAUTH: Login failed for user x with service imap from IP 203.0.113.1'))
self.assertEqual(self.regexes.match('MAILCOWAUTH: Login failed for user x with service imap from IP 203.0.113.1 '
'with service smtp from IP 198.51.100.7'),
('3', '198.51.100.7'))
self.assertEqual(self.regexes.match("[1]: SOGoRootPage Login from '198.51.100.7' for user 'x' might not have worked "
"SOGoRootPage Login from '203.0.113.1' for user y might not have worked"), ('6', '198.51.100.7'))
# nginx appends the client to the X-Forwarded-For header a client sent
self.assertEqual(self.regexes.match("[1]: SOGoRootPage Login from '203.0.113.1, 198.51.100.7' for user 'x' might not have worked"),
('6', '198.51.100.7'))
# lines of the web UI are not sanitized like syslog lines
self.assertEqual(self.regexes.match('mailcow UI: Invalid password for admin\n by 203.0.113.5'), ('1', '203.0.113.5'))
def test_former_defaults_are_replaced(self):
self.r.set('F2B_REGEX', json.dumps(self.FORMER))
self.regexes.refresh(self.r)
defaults = {str(rule_id): regex for rule_id, regex in Regexes.DEFAULTS.items()}
expected = dict(defaults, **{'8': defaults['6'], '9': defaults['7']})
del expected['6'], expected['7']
self.assertEqual(json.loads(self.r.get('F2B_REGEX')), expected)
self.assertEqual(self.regexes.match(self.LINES['3'][0]), ('3', '203.0.113.6'))
self.assertEqual(self.regexes.match(self.LINES['6'][0]), ('8', '203.0.113.11'))
def test_older_defaults_are_replaced(self):
self.r.set('F2B_REGEX', json.dumps(self.OLDER))
self.regexes.refresh(self.r)
stored = json.loads(self.r.get('F2B_REGEX'))
self.assertEqual(sorted(stored, key=int), ['1', '2', '3', '4', '5', '9', '10'])
self.assertEqual(stored['9'], Regexes.DEFAULTS[6])
self.assertEqual(stored['10'], self.OLDER['10'])
def test_changed_rules_are_kept(self):
custom = {'1': r'mailcow UI: Invalid password for (?:x|y) by ([0-9a-f\.:]+)', '2': self.FORMER['3'], '3': r'custom ([0-9\.]+)'}
self.r.set('F2B_REGEX', json.dumps(custom))
self.regexes.refresh(self.r)
self.assertEqual(json.loads(self.r.get('F2B_REGEX')), dict(custom, **{'2': Regexes.DEFAULTS[3]}))
# current rules are not written again
raw = self.r.get('F2B_REGEX')
self.regexes.refresh(self.r)
self.assertEqual(self.r.get('F2B_REGEX'), raw)
def test_only_the_first_matching_rule_counts(self): def test_only_the_first_matching_rule_counts(self):
self.r.set('F2B_REGEX', json.dumps({'2': r'by ([0-9a-f\.:]+)', '10': r'from ([0-9a-f\.:]+)', '1': r'login from ([0-9a-f\.:]+)'})) self.r.set('F2B_REGEX', json.dumps({'2': r'by ([0-9a-f\.:]+)', '10': r'from ([0-9a-f\.:]+)', '1': r'login from ([0-9a-f\.:]+)'}))
@@ -58,13 +132,6 @@ class RegexesTest(unittest.TestCase):
self.regexes.refresh(self.r) self.regexes.refresh(self.r)
self.assertIsNone(self.regexes.match('failed by nobody')) self.assertIsNone(self.regexes.match('failed by nobody'))
def test_temporary_failure(self):
worker = 'auth-worker(3933): Error: conn unix:auth-worker (pid=3932,uid=401): auth-worker<4>: '
self.assertEqual(Regexes.temporary_failure(worker + 'lua(a@example.org,203.0.113.1,<GGexLdBezoasFgEB>): passdb-lua: Upstream unavailable'), '203.0.113.1')
self.assertEqual(Regexes.temporary_failure(worker + 'lua(a@example.org,2001:db8::1): passdb-lua: Upstream unreachable'), '2001:db8::1')
self.assertIsNone(Regexes.temporary_failure('auth: lua(a@example.org,203.0.113.1,<GGexLdBezoasFgEB>): unknown user (SHA1 of given password: 0123ab)'))
self.assertIsNone(Regexes.temporary_failure('warning: unknown[203.0.113.1]: SASL LOGIN authentication failed: (reason unavailable)'))
def test_parse_ip(self): def test_parse_ip(self):
self.assertEqual(Regexes.parse_ip('203.0.113.1'), ipaddress.ip_address('203.0.113.1')) self.assertEqual(Regexes.parse_ip('203.0.113.1'), ipaddress.ip_address('203.0.113.1'))
self.assertEqual(Regexes.parse_ip('203.0.113.1:'), ipaddress.ip_address('203.0.113.1')) self.assertEqual(Regexes.parse_ip('203.0.113.1:'), ipaddress.ip_address('203.0.113.1'))