mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-05 07:58:20 +00:00
[Netfilter][Dovecot] Count failed master logins, log the client address of successful logins
This commit is contained in:
4 files changed
+34
-13
No files matched your search
@@ -8,6 +8,7 @@ class Regexes:
|
|||||||
|
|
||||||
# Anchored to the line start: text clients control in other lines (login names, SMTP commands) must not match.
|
# 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.
|
# 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 = {
|
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\.:]+)$',
|
||||||
@@ -16,6 +17,7 @@ class Regexes:
|
|||||||
5: r'^NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
|
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',
|
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 .+',
|
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.
|
# Former default rules (with any rule id) => rule id of their replacement, None: the rule is removed.
|
||||||
# Rules the admin changed are kept.
|
# 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.+ \(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'-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: 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]+\)': None,
|
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]+\)': None,
|
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,
|
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
|
# Lines of the web UI are not sanitized like syslog lines: a newline in a login name must not hide the address
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ 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
|
// 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'];
|
$client_ip = filter_var($post['client_ip'] ?? '', FILTER_VALIDATE_IP) ? $post['client_ip'] : $post['real_rip'];
|
||||||
$result = false;
|
$result = false;
|
||||||
if ($isSOGoRequest) {
|
if ($isSOGoRequest) {
|
||||||
@@ -76,7 +76,7 @@ if ($isSOGoRequest) {
|
|||||||
$sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass");
|
$sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass");
|
||||||
if ($sogo_sso_pass === $post['password']){
|
if ($sogo_sso_pass === $post['password']){
|
||||||
error_log('MAILCOWAUTH: SOGo SSO auth for user ' . $post['username']);
|
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;
|
$result = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,8 +95,8 @@ if ($result === false){
|
|||||||
'remote_addr' => $post['real_rip']
|
'remote_addr' => $post['real_rip']
|
||||||
));
|
));
|
||||||
if ($result) {
|
if ($result) {
|
||||||
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $post['real_rip']);
|
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip);
|
||||||
set_sasl_log($post['username'], $post['real_rip'], $service);
|
set_sasl_log($post['username'], $client_ip, $service);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($result === false){
|
if ($result === false){
|
||||||
@@ -107,15 +107,15 @@ if ($result === false){
|
|||||||
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']));
|
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']));
|
||||||
} catch (\Mailcow\Auth\IdpUnavailable $e) {
|
} catch (\Mailcow\Auth\IdpUnavailable $e) {
|
||||||
// temporary failure for Dovecot, no failed login
|
// 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
|
http_response_code(503); // Service Unavailable
|
||||||
echo json_encode($return);
|
echo json_encode($return);
|
||||||
session_destroy();
|
session_destroy();
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if ($result) {
|
if ($result) {
|
||||||
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
|
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip);
|
||||||
set_sasl_log($post['username'], $post['real_rip'], $post['service']);
|
set_sasl_log($post['username'], $client_ip, $post['service']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ $RSPAMD_MAPS = array(
|
|||||||
// true = option requires a non-empty value
|
// true = option requires a non-empty value
|
||||||
// false = pure flag, must NOT have a value
|
// false = pure flag, must NOT have a value
|
||||||
// string = PCRE (no delimiters) the whole non-empty value must match
|
// 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']);
|
// 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).
|
// 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'
|
// NEVER add (RCE): 'delete2foldersonly', 'delete2foldersbutnot', 'regexflag', 'regexmess', 'pipemess', 'regextrans2', 'maxlinelengthcmd', 'oauthrefreshcmd1', 'oauthrefreshcmd2'
|
||||||
|
|||||||
@@ -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 - "
|
'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'),
|
"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'),
|
'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,<qIIX9EZfYISsFgEB>): '
|
||||||
|
'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
|
# Defaults of 2025-02 to 2026-10 and of 2022-11 to 2025-02
|
||||||
FORMER = {
|
FORMER = {
|
||||||
@@ -59,6 +61,18 @@ class RegexesTest(unittest.TestCase):
|
|||||||
'method=PLAIN, rip=203.0.113.6, lip=172.22.1.250'):
|
'method=PLAIN, rip=203.0.113.6, lip=172.22.1.250'):
|
||||||
self.assertIsNone(self.regexes.match(line), line)
|
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,<dfbigUdfZJysFgEB>): '
|
||||||
|
'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,<dfbigUdfZJysFgEB>): Password mismatch'))
|
||||||
|
self.assertIsNone(self.regexes.match('auth: lua(a@example.org,203.0.113.13,master,<dfbigUdfZJysFgEB>): unknown user'))
|
||||||
|
|
||||||
def test_text_of_clients_does_not_match(self):
|
def test_text_of_clients_does_not_match(self):
|
||||||
# Login names, SMTP commands and addresses of other lines must not ban a foreign address
|
# Login names, SMTP commands and addresses of other lines must not ban a foreign address
|
||||||
self.regexes.refresh(self.r)
|
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
|
# 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"),
|
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'))
|
('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,<a>): Password mismatch,198.51.100.7,master,<b>): '
|
||||||
|
'Password mismatch (SHA1 of given password: 6d0653)'), ('8', '198.51.100.7'))
|
||||||
# lines of the web UI are not sanitized like syslog lines
|
# 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'))
|
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.r.set('F2B_REGEX', json.dumps(self.FORMER))
|
||||||
self.regexes.refresh(self.r)
|
self.regexes.refresh(self.r)
|
||||||
defaults = {str(rule_id): regex for rule_id, regex in Regexes.DEFAULTS.items()}
|
defaults = {str(rule_id): regex for rule_id, regex in Regexes.DEFAULTS.items()}
|
||||||
expected = dict(defaults, **{'8': defaults['6'], '9': defaults['7']})
|
# the rule for failed Dovecot logins counts failed master logins now
|
||||||
del expected['6'], expected['7']
|
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(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['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['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):
|
def test_older_defaults_are_replaced(self):
|
||||||
self.r.set('F2B_REGEX', json.dumps(self.OLDER))
|
self.r.set('F2B_REGEX', json.dumps(self.OLDER))
|
||||||
|
|||||||
Reference in new issue
Block a user