From a4b60ae26f1bb4d8b2a2b2a283b0c11ce856bd81 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:55:42 +0100 Subject: [PATCH] [Netfilter][Dovecot] Count failed master logins, log the client address of successful logins --- data/Dockerfiles/netfilter/modules/Regexes.py | 8 ++++--- data/conf/dovecot/auth/mailcowauth.php | 14 +++++------ data/web/inc/vars.inc.php | 2 +- tests/netfilter/test_regexes.py | 23 +++++++++++++++++-- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/data/Dockerfiles/netfilter/modules/Regexes.py b/data/Dockerfiles/netfilter/modules/Regexes.py index 9cfd00bfd..2b781ba0a 100644 --- a/data/Dockerfiles/netfilter/modules/Regexes.py +++ b/data/Dockerfiles/netfilter/modules/Regexes.py @@ -8,6 +8,7 @@ class Regexes: # 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. + # Only the master passdb is checked by Dovecot itself (rule 8); the last address is the one Dovecot added. DEFAULTS = { 1: r'^mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)$', 2: r'^Rspamd UI: Invalid password by ([0-9a-f\.:]+)$', @@ -16,6 +17,7 @@ class Regexes: 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 .+', + 8: r'^auth: passwd-file\(.*,([0-9a-f\.:]+),master(?:,<[^<>]*>)?\): (?:Password mismatch|unknown user)', } # Former default rules (with any rule id) => rule id of their replacement, None: the rule is removed. # Rules the admin changed are kept. @@ -34,9 +36,9 @@ class Regexes: 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'auth: static\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8, + r'auth: \w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8, + r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8, 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 diff --git a/data/conf/dovecot/auth/mailcowauth.php b/data/conf/dovecot/auth/mailcowauth.php index e18e8fcec..2865ea083 100644 --- a/data/conf/dovecot/auth/mailcowauth.php +++ b/data/conf/dovecot/auth/mailcowauth.php @@ -68,7 +68,7 @@ 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 +// real_rip is the address that connected to Dovecot (for proxied connections the proxy), client_ip the client (for logs) $client_ip = filter_var($post['client_ip'] ?? '', FILTER_VALIDATE_IP) ? $post['client_ip'] : $post['real_rip']; $result = false; if ($isSOGoRequest) { @@ -76,7 +76,7 @@ if ($isSOGoRequest) { $sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass"); if ($sogo_sso_pass === $post['password']){ error_log('MAILCOWAUTH: SOGo SSO auth for user ' . $post['username']); - set_sasl_log($post['username'], $post['real_rip'], "SOGO"); + set_sasl_log($post['username'], $client_ip, "SOGO"); $result = true; } } @@ -95,8 +95,8 @@ if ($result === false){ 'remote_addr' => $post['real_rip'] )); if ($result) { - error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $post['real_rip']); - set_sasl_log($post['username'], $post['real_rip'], $service); + error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip); + set_sasl_log($post['username'], $client_ip, $service); } } if ($result === false){ @@ -107,15 +107,15 @@ if ($result === false){ $result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service'])); } catch (\Mailcow\Auth\IdpUnavailable $e) { // temporary failure for Dovecot, no failed login - error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip'] . ": " . $e->getMessage()); + error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip . ": " . $e->getMessage()); http_response_code(503); // Service Unavailable echo json_encode($return); session_destroy(); exit; } if ($result) { - error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']); - set_sasl_log($post['username'], $post['real_rip'], $post['service']); + error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip); + set_sasl_log($post['username'], $client_ip, $post['service']); } } diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php index 3892ae315..805f27139 100644 --- a/data/web/inc/vars.inc.php +++ b/data/web/inc/vars.inc.php @@ -291,7 +291,7 @@ $RSPAMD_MAPS = array( // true = option requires a non-empty value // false = pure flag, must NOT have a value // string = PCRE (no delimiters) the whole non-empty value must match -// Anything not listed here is rejected when a job is saved (the runner passes stored options as they are). +// Anything not listed here is rejected when a job is saved; the runner passes stored options as they are, except the blocked ones below. // Override in vars.local.inc.php per option, e.g. $IMAPSYNC_OPTIONS['someoption'] = true; or unset($IMAPSYNC_OPTIONS['debug']); // The old format $IMAPSYNC_OPTIONS['whitelist'] (list of names) is no longer supported and ignored (logged). // NEVER add (RCE): 'delete2foldersonly', 'delete2foldersbutnot', 'regexflag', 'regexmess', 'pipemess', 'regextrans2', 'maxlinelengthcmd', 'oauthrefreshcmd1', 'oauthrefreshcmd2' diff --git a/tests/netfilter/test_regexes.py b/tests/netfilter/test_regexes.py index 3a4bf6b77..30cbea970 100644 --- a/tests/netfilter/test_regexes.py +++ b/tests/netfilter/test_regexes.py @@ -17,6 +17,8 @@ class RegexesTest(unittest.TestCase): '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'), + '8': ('auth: passwd-file(master@mailcow.local,203.0.113.13,master,): ' + 'Password mismatch (SHA1 of given password: 05d839)', '203.0.113.13'), } # Defaults of 2025-02 to 2026-10 and of 2022-11 to 2025-02 FORMER = { @@ -59,6 +61,18 @@ class RegexesTest(unittest.TestCase): 'method=PLAIN, rip=203.0.113.6, lip=172.22.1.250'): self.assertIsNone(self.regexes.match(line), line) + def test_failed_master_logins(self): + # Dovecot checks the master passdb itself, mailcowauth.php is not asked + self.regexes.refresh(self.r) + self.assertEqual(self.regexes.match('auth: passwd-file(x@mailcow.local,2001:db8::13,master,): ' + 'unknown user (SHA1 of given password: 6d0653)'), ('8', '2001:db8::13')) + # no session of Postfix + self.assertEqual(self.regexes.match('auth: passwd-file(x@mailcow.local,203.0.113.13,master): ' + 'Password mismatch (SHA1 of given password: 6d0653)'), ('8', '203.0.113.13')) + # without the client address (doveadm) or for the user of a master login + self.assertIsNone(self.regexes.match('auth: passwd-file(x@mailcow.local,master,): Password mismatch')) + self.assertIsNone(self.regexes.match('auth: lua(a@example.org,203.0.113.13,master,): unknown user')) + 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) @@ -78,6 +92,9 @@ class RegexesTest(unittest.TestCase): # 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')) + # the address Dovecot adds follows the login name + self.assertEqual(self.regexes.match('auth: passwd-file(x,203.0.113.1,master,): Password mismatch,198.51.100.7,master,): ' + 'Password mismatch (SHA1 of given password: 6d0653)'), ('8', '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')) @@ -85,11 +102,13 @@ class RegexesTest(unittest.TestCase): 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'] + # the rule for failed Dovecot logins counts failed master logins now + expected = dict(defaults, **{'6': defaults['8'], '8': defaults['6'], '9': defaults['7']}) + del 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')) + self.assertEqual(self.regexes.match(self.LINES['8'][0]), ('6', '203.0.113.13')) def test_older_defaults_are_replaced(self): self.r.set('F2B_REGEX', json.dumps(self.OLDER))