mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-05 07:58:20 +00:00
[Dovecot][Netfilter] Report failed logins from mailcowauth.php, anchor default rules
This commit is contained in:
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
|
||||
(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):
|
||||
self.r = r
|
||||
self.tables = tables
|
||||
@@ -21,16 +18,6 @@ class BanManager:
|
||||
self.banned = set()
|
||||
self.denied = {}
|
||||
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):
|
||||
"""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:
|
||||
self.logger.logInfo('Address %s is allowlisted by rule %s' % (ip, allowed))
|
||||
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:
|
||||
# log lines of connections from before the ban
|
||||
return
|
||||
@@ -208,8 +192,6 @@ class BanManager:
|
||||
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]:
|
||||
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):
|
||||
for net in self.tables.ban(nets):
|
||||
|
||||
@@ -109,12 +109,6 @@ class Netfilter:
|
||||
def handle_line(self, line):
|
||||
# a broken line must not end the watcher
|
||||
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)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
@@ -6,20 +6,41 @@ import re
|
||||
class Regexes:
|
||||
"""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 = {
|
||||
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 .+',
|
||||
1: r'^mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)$',
|
||||
2: r'^Rspamd UI: Invalid password by ([0-9a-f\.:]+)$',
|
||||
3: r'^MAILCOWAUTH: Login failed for user .+ with service \w* from IP ([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.+',
|
||||
6: r'^(?:\[\d+\]: )?SOGo\w* Login from \'(?:[0-9a-f\.:]+, )*([0-9a-f\.:]+)\' for user .+ might not have worked',
|
||||
7: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
|
||||
}
|
||||
# Dovecot could not ask the auth backend (identity provider down, mailcowauth.php unreachable). Postfix logs
|
||||
# such a temporary failure like a wrong password, so failed logins of the address are not counted for a while.
|
||||
TEMPORARY_FAILURE = re.compile(r'lua\([^,]+,([0-9a-f\.:]+)(?:,<[^>]+>)?\): passdb-lua: Upstream (?:unavailable|unreachable)')
|
||||
# Former default rules (with any rule id) => rule id of their replacement, None: the rule is removed.
|
||||
# Rules the admin changed are kept.
|
||||
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):
|
||||
self.logger = logger
|
||||
@@ -42,6 +63,10 @@ class Regexes:
|
||||
if not isinstance(stored, dict):
|
||||
self.logger.logWarn('F2B_REGEX is no JSON object, keeping the current rules')
|
||||
return
|
||||
migrated = self.migrate(stored)
|
||||
if migrated != stored:
|
||||
self.store_migrated(r)
|
||||
stored = migrated
|
||||
|
||||
rules = []
|
||||
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):
|
||||
"""(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:
|
||||
result = regex.search(line)
|
||||
if result and result.lastindex and result.group(1):
|
||||
@@ -61,10 +87,35 @@ class Regexes:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def temporary_failure(cls, line):
|
||||
"""Address of a login that failed because the auth backend was unavailable, None for other lines"""
|
||||
result = cls.TEMPORARY_FAILURE.search(line)
|
||||
return result.group(1) if result else None
|
||||
def migrate(cls, stored):
|
||||
"""The stored rules with former default rules replaced by the current ones (or removed)"""
|
||||
migrated = {}
|
||||
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
|
||||
def parse_ip(text):
|
||||
|
||||
@@ -68,6 +68,8 @@ require_once 'functions.acl.inc.php';
|
||||
|
||||
|
||||
$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;
|
||||
if ($isSOGoRequest) {
|
||||
// This is a SOGo Auth request. First check for SSO password.
|
||||
@@ -121,7 +123,14 @@ if ($result) {
|
||||
http_response_code(200); // OK
|
||||
$return['success'] = true;
|
||||
} 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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
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 ltn12 = require "ltn12"
|
||||
local https = require "ssl.https"
|
||||
@@ -11,7 +7,10 @@ function auth_password_verify(request, password)
|
||||
local req = {
|
||||
username = request.user,
|
||||
password = password,
|
||||
-- address that connected to Dovecot, for proxied connections the proxy
|
||||
real_rip = request.real_rip,
|
||||
-- address of the client, also for proxied connections
|
||||
client_ip = request.rip,
|
||||
service = request.service
|
||||
}
|
||||
local req_json = json.encode(req)
|
||||
|
||||
@@ -51,13 +51,6 @@ recipient_delimiter = +
|
||||
auth_master_user_separator = *
|
||||
mail_shared_explicit_inbox = yes
|
||||
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
|
||||
passdb {
|
||||
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
|
||||
# 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
|
||||
# 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 {
|
||||
driver = lua
|
||||
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
|
||||
|
||||
+1
-1
@@ -515,7 +515,7 @@ services:
|
||||
- acme
|
||||
|
||||
netfilter-mailcow:
|
||||
image: ghcr.io/mailcow/netfilter:1.66
|
||||
image: ghcr.io/mailcow/netfilter:1.67
|
||||
stop_grace_period: 30s
|
||||
restart: always
|
||||
privileged: true
|
||||
|
||||
@@ -188,25 +188,6 @@ class BanManagerTest(unittest.TestCase):
|
||||
self.bans.purge()
|
||||
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__':
|
||||
unittest.main()
|
||||
@@ -25,8 +25,8 @@ class NetfilterTest(unittest.TestCase):
|
||||
netfilter.regexes.refresh(self.r)
|
||||
return netfilter
|
||||
|
||||
def sasl(self, address):
|
||||
return 'warning: unknown[%s]: SASL LOGIN authentication failed: UGFzc3dvcmQ6' % address
|
||||
def failed(self, 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):
|
||||
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):
|
||||
netfilter = self.create()
|
||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
||||
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||
self.r.hset('F2B_PERM_BANS', '198.51.100.1', '1')
|
||||
netfilter.shutdown()
|
||||
self.assertEqual(self.tables.rules, set())
|
||||
@@ -59,52 +59,51 @@ class NetfilterTest(unittest.TestCase):
|
||||
|
||||
def test_handle_line_bans(self):
|
||||
netfilter = self.create()
|
||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
||||
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||
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):
|
||||
netfilter = self.create()
|
||||
netfilter.handle_line(self.sasl('1.2.3.4:'))
|
||||
netfilter.handle_line(self.sasl('1.2.3.4:'))
|
||||
netfilter.handle_line(self.failed('1.2.3.4:'))
|
||||
netfilter.handle_line(self.failed('1.2.3.4:'))
|
||||
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
|
||||
|
||||
def test_handle_line_ignores_private_and_invalid_addresses(self):
|
||||
netfilter = self.create()
|
||||
for _ in range(3):
|
||||
netfilter.handle_line(self.sasl('192.168.1.1'))
|
||||
netfilter.handle_line(self.sasl('::1'))
|
||||
netfilter.handle_line(self.sasl('1.2.3'))
|
||||
netfilter.handle_line(self.failed('192.168.1.1'))
|
||||
netfilter.handle_line(self.failed('::1'))
|
||||
netfilter.handle_line(self.failed('1.2.3'))
|
||||
self.assertEqual(self.tables.rules, set())
|
||||
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):
|
||||
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)
|
||||
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
|
||||
|
||||
def test_unavailable_auth_backend_is_no_failed_login(self):
|
||||
# Dovecot logs the temporary failure, Postfix then the failed SASL login like for a wrong password
|
||||
def test_postfix_and_dovecot_lines_are_no_failed_logins(self):
|
||||
# mailcowauth.php reports failed logins, not when the auth backend was unavailable
|
||||
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):
|
||||
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.assertNotIn('1.2.3.4/32', self.r.hgetall('F2B_ACTIVE_BANS'))
|
||||
|
||||
def test_handle_line_survives_errors(self):
|
||||
netfilter = self.create()
|
||||
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')))
|
||||
|
||||
def test_watch_reconnects_after_errors(self):
|
||||
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)
|
||||
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
|
||||
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):
|
||||
netfilter = self.create()
|
||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
||||
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||
self.tables.rules = set()
|
||||
self.tables.calls = []
|
||||
self.tables.recreate = True
|
||||
@@ -151,8 +150,8 @@ class NetfilterTest(unittest.TestCase):
|
||||
self.r.hset('F2B_BLACKLIST', '198.51.100.1', 1)
|
||||
netfilter.update_lists()
|
||||
self.assertEqual(self.tables.rules, {'198.51.100.1/32'})
|
||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
||||
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||
self.assertNotIn('1.2.3.4/32', self.tables.rules)
|
||||
|
||||
def test_resolve_keeps_addresses_on_dns_errors(self):
|
||||
|
||||
@@ -10,14 +10,33 @@ class RegexesTest(unittest.TestCase):
|
||||
LINES = {
|
||||
'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'),
|
||||
'3': ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: UGFzc3dvcmQ6', '203.0.113.6'),
|
||||
'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'),
|
||||
'6': ('auth: lua(user@example.org,203.0.113.9,<abcdef>): Password mismatch (SHA1 of given password: 0123ab)', '203.0.113.9'),
|
||||
'7': ('auth: lua(nobody@example.org,203.0.113.10,<abcdef>): unknown user (SHA1 of given password: 0123ab)', '203.0.113.10'),
|
||||
'8': ("SOGo[123]: Login from '203.0.113.11' for user nobody might not have worked", '203.0.113.11'),
|
||||
'9': ('203.0.113.12 "GET /SOGo/so/ HTTP/1.1" 403 123 "-"', '203.0.113.12'),
|
||||
'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'),
|
||||
'5': ('NOQUEUE: reject: RCPT from [203.0.113.8]:52516: 550 5.5.1 Protocol error; from=<a@example.org> to=<b@example.org>',
|
||||
'203.0.113.8'),
|
||||
'6': ("[6714]: SOGoRootPage Login from '203.0.113.11' for user 'nobody@example.org' might not have worked - "
|
||||
"password policy: 65535 grace: -1 expire: -1 bound: 0", '203.0.113.11'),
|
||||
'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):
|
||||
self.r = FakeRedis()
|
||||
@@ -30,10 +49,65 @@ class RegexesTest(unittest.TestCase):
|
||||
for rule_id, (line, address) in self.LINES.items():
|
||||
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)
|
||||
line = 'warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: Connection lost to authentication server'
|
||||
self.assertIsNone(self.regexes.match(line))
|
||||
for line in ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org',
|
||||
'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):
|
||||
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.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):
|
||||
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'))
|
||||
|
||||
Reference in new issue
Block a user