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
@@ -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