Merge pull request #7567 from mailcow/feat/netfilter-rework

[Netfilter] Rework: robust bans, no restart loops, identity provider outages are no failed logins
This commit is contained in:
FreddleSpl0it authored and GitHub committed 2026-10-28 11:24:29 +01:00
commit 9cc8ac2908
40 files changed
+2313 -1240

No files matched your search

+25 -30
View File
@@ -36,7 +36,18 @@ function check_login($user, $pass, $extra = null) {
// Try validate user
if (!isset($role) || $role == "user") {
$result = user_login($user, $pass, $extra);
try {
$result = user_login($user, $pass, $extra);
} catch (\Mailcow\Auth\IdpUnavailable $e) {
// no failed login, netfilter must not count it
error_log("mailcow UI: Identity provider unavailable for " . $user . " by " . $_SERVER['REMOTE_ADDR'] . ": " . $e->getMessage());
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $user, '*', $e->getMessage()),
'msg' => 'iam_unavailable'
);
return false;
}
if ($result !== false) {
$real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
set_sasl_log($user, $real_rip, $extra['service']);
@@ -516,11 +527,14 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
return false;
}
if (!$iam_provider) {
return false;
throw new \Mailcow\Auth\IdpUnavailable('Keycloak is not configured completely');
}
// get access_token for service account of mailcow client
$admin_token = identity_provider("get-keycloak-admin-token");
if (!$admin_token) {
throw new \Mailcow\Auth\IdpUnavailable('Could not get an admin token from Keycloak');
}
// get the mailcow_password attribute from keycloak user
$url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users";
@@ -541,13 +555,9 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
$users = json_decode(curl_exec($curl), true);
$code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
// the password is checked below, a failed request is never a wrong password
if ($code != 200) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $user, '*', 'Identity Provider returned HTTP ' . $code),
'msg' => 'generic_server_error'
);
return false;
throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code);
}
// exact=true can still return several users with that email
if (!is_array($users) || count($users) != 1) {
@@ -687,7 +697,8 @@ function ldap_mbox_login($user, $pass, $extra = null){
return false;
}
if (!$iam_provider) {
return false;
// identity_provider('init') could not connect
throw new \Mailcow\Auth\IdpUnavailable('No connection to the LDAP server');
}
try {
@@ -700,33 +711,17 @@ function ldap_mbox_login($user, $pass, $extra = null){
\Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? '')))));
$user_res = $ldap_query->firstOrFail();
} catch (Exception $e) {
// clear $_SESSION['return'] to not leak data
$_SESSION['return'] = array();
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $user, '*', $e->getMessage()),
'msg' => 'generic_server_error'
);
} catch (\LdapRecord\Query\ObjectNotFoundException $e) {
return false;
}
try {
if (!$iam_provider->auth()->attempt($user_res['dn'], $pass)) {
return false;
}
} catch (Exception $e) {
// clear $_SESSION['return'] to not leak data
$_SESSION['return'] = array();
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $user, '*', $e->getMessage()),
'msg' => 'generic_server_error'
);
throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e);
}
$idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp;
if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) {
return false;
}
// get mapped template
$idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp;
$mapped_template = $idp_login->mappedTemplate($user_res[$iam_settings['attribute_field']] ?? array());
$external_id = \Mailcow\Identity\Directory\LdapDirectory::entryId($user_res, $iam_settings['id_attribute'] ?? '');
+62 -63
View File
@@ -60,13 +60,14 @@ function fail2ban($_action, $_data = null, $_extra = null) {
$queue_unban = $redis->hGetAll('F2B_QUEUE_UNBAN');
if (is_array($active_bans)) {
foreach ($active_bans as $network => $banned_until) {
$queued_for_unban = (isset($queue_unban[$network]) && $queue_unban[$network] == 1) ? 1 : 0;
$difference = $banned_until - time();
// Expired bans are lifted by netfilter within seconds
$difference = max(0, intval($banned_until) - time());
$queued_for_unban = ($difference == 0 || (isset($queue_unban[$network]) && $queue_unban[$network] == 1)) ? 1 : 0;
$f2b_options['active_bans'][] = array(
'queued_for_unban' => $queued_for_unban,
'network' => $network,
'ip' => strtok($network,'/'),
'banned_until' => sprintf('%02dh %02dm %02ds', ($difference/3600), ($difference/60%60), $difference%60)
'banned_until' => sprintf('%02dh %02dm %02ds', intdiv($difference, 3600), intdiv($difference, 60) % 60, $difference % 60)
);
}
}
@@ -108,16 +109,15 @@ function fail2ban($_action, $_data = null, $_extra = null) {
);
return false;
}
// Rules will also be recreated on log events, but rules may seem empty for a second in the UI
docker('post', 'netfilter-mailcow', 'restart');
// netfilter recreates the default rules within 10 seconds
$fail_count = 0;
$regex_result = json_decode($redis->Get('F2B_REGEX'), true);
while (empty($regex_result) && $fail_count < 10) {
while (empty($regex_result) && $fail_count < 15) {
$regex_result = json_decode($redis->Get('F2B_REGEX'), true);
$fail_count++;
sleep(1);
}
if ($fail_count >= 10) {
if (empty($regex_result)) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
@@ -141,7 +141,7 @@ function fail2ban($_action, $_data = null, $_extra = null) {
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => array('object_modified', htmlspecialchars($network))
'msg' => 'f2b_modified'
);
return true;
}
@@ -172,7 +172,7 @@ function fail2ban($_action, $_data = null, $_extra = null) {
if (valid_network($network)) {
try {
$redis->hSet('F2B_WHITELIST', $network, 1);
$redis->hDel('F2B_BLACKLIST', $network, 1);
$redis->hDel('F2B_BLACKLIST', $network);
$redis->hSet('F2B_QUEUE_UNBAN', $network, 1);
}
catch (RedisException $e) {
@@ -199,14 +199,13 @@ function fail2ban($_action, $_data = null, $_extra = null) {
if (valid_network($network) && !in_array($network, array(
'0.0.0.0',
'0.0.0.0/0',
getenv('IPV4_NETWORK') . '0/24',
getenv('IPV4_NETWORK') . '0',
getenv('IPV4_NETWORK') . '.0/24',
getenv('IPV4_NETWORK') . '.0',
getenv('IPV6_NETWORK')
))) {
try {
$redis->hSet('F2B_BLACKLIST', $network, 1);
$redis->hDel('F2B_WHITELIST', $network, 1);
//$response = docker('post', 'netfilter-mailcow', 'restart');
$redis->hDel('F2B_WHITELIST', $network);
}
catch (RedisException $e) {
$_SESSION['return'][] = array(
@@ -261,62 +260,60 @@ function fail2ban($_action, $_data = null, $_extra = null) {
$f2b_options['ban_time'] = ($ban_time < 60) ? 60 : $ban_time;
$f2b_options['ban_time_increment'] = ($ban_time_increment == 1) ? true : false;
$f2b_options['max_ban_time'] = ($max_ban_time < 60) ? 60 : $max_ban_time;
$f2b_options['netban_ipv4'] = ($netban_ipv4 < 8) ? 8 : $netban_ipv4;
$f2b_options['netban_ipv6'] = ($netban_ipv6 < 8) ? 8 : $netban_ipv6;
$f2b_options['netban_ipv4'] = ($netban_ipv4 > 32) ? 32 : $netban_ipv4;
$f2b_options['netban_ipv6'] = ($netban_ipv6 > 128) ? 128 : $netban_ipv6;
$f2b_options['netban_ipv4'] = min(32, max(8, $netban_ipv4));
$f2b_options['netban_ipv6'] = min(128, max(8, $netban_ipv6));
$f2b_options['max_attempts'] = ($max_attempts < 1) ? 1 : $max_attempts;
$f2b_options['retry_window'] = ($retry_window < 1) ? 1 : $retry_window;
$f2b_options['banlist_id'] = $is_now['banlist_id'];
$f2b_options['banlist_id'] = (!empty($is_now['banlist_id'])) ? $is_now['banlist_id'] : uuid4();
$f2b_options['manage_external'] = ($manage_external > 0) ? 1 : 0;
$wl_items = array();
if (!empty($wl)) {
foreach (array_filter(array_map('trim', preg_split("/( |,|;|\n)/", $wl))) as $wl_item) {
if (valid_network($wl_item) || valid_hostname($wl_item)) {
$wl_items[$wl_item] = 1;
}
else {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => array('network_host_invalid', $wl_item)
);
}
}
}
$bl_items = array();
if (!empty($bl)) {
foreach (array_filter(array_map('trim', preg_split("/( |,|;|\n)/", $bl))) as $bl_item) {
if (valid_network($bl_item) && !in_array($bl_item, array(
'0.0.0.0',
'0.0.0.0/0',
getenv('IPV4_NETWORK') . '.0/24',
getenv('IPV4_NETWORK') . '.0',
getenv('IPV6_NETWORK')
))) {
$bl_items[$bl_item] = 1;
}
else {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => array('network_host_invalid', $bl_item)
);
}
}
}
try {
// One transaction, netfilter must never see empty lists in between
$redis->multi();
$redis->Set('F2B_OPTIONS', json_encode($f2b_options));
$redis->Del('F2B_WHITELIST');
$redis->Del('F2B_BLACKLIST');
if(!empty($wl)) {
$wl_array = array_map('trim', preg_split( "/( |,|;|\n)/", $wl));
$wl_array = array_filter($wl_array);
if (is_array($wl_array)) {
foreach ($wl_array as $wl_item) {
if (valid_network($wl_item) || valid_hostname($wl_item)) {
$redis->hSet('F2B_WHITELIST', $wl_item, 1);
}
else {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => array('network_host_invalid', $wl_item)
);
continue;
}
}
}
$redis->Del('F2B_WHITELIST', 'F2B_BLACKLIST');
if (!empty($wl_items)) {
$redis->hMSet('F2B_WHITELIST', $wl_items);
}
if(!empty($bl)) {
$bl_array = array_map('trim', preg_split( "/( |,|;|\n)/", $bl));
$bl_array = array_filter($bl_array);
if (is_array($bl_array)) {
foreach ($bl_array as $bl_item) {
if (valid_network($bl_item) && !in_array($bl_item, array(
'0.0.0.0',
'0.0.0.0/0',
getenv('IPV4_NETWORK') . '0/24',
getenv('IPV4_NETWORK') . '0',
getenv('IPV6_NETWORK')
))) {
$redis->hSet('F2B_BLACKLIST', $bl_item, 1);
}
else {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => array('network_host_invalid', $bl_item)
);
continue;
}
}
}
if (!empty($bl_items)) {
$redis->hMSet('F2B_BLACKLIST', $bl_items);
}
$redis->exec();
}
catch (RedisException $e) {
$_SESSION['return'][] = array(
@@ -348,7 +345,9 @@ function fail2ban($_action, $_data = null, $_extra = null) {
if (is_array($_extra)) {
$_extra = $_extra[0];
}
if ($_extra != $f2b_options['banlist_id']){
// Timing safe, a missing or empty id never matches
$banlist_id = (string)($f2b_options['banlist_id'] ?? '');
if ($banlist_id === '' || !is_string($_extra) || !hash_equals($banlist_id, $_extra)) {
http_response_code(404);
return false;
}
+7 -2
View File
@@ -1292,8 +1292,13 @@ function set_tfa($_data) {
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
if ($row['authsource'] == 'ldap'){
if (!ldap_mbox_login($username, $_data["confirm_password"], $iam_settings)) $access_denied = true;
else $access_denied = false;
try {
if (!ldap_mbox_login($username, $_data["confirm_password"], $iam_settings)) $access_denied = true;
else $access_denied = false;
} catch (\Mailcow\Auth\IdpUnavailable $e) {
$_SESSION['return'][] = array('type' => 'danger', 'log' => array(__FUNCTION__, $_data_log, $e->getMessage()), 'msg' => 'iam_unavailable');
return false;
}
} else {
if (!verify_hash($row['password'], $_data["confirm_password"])) $access_denied = true;
else $access_denied = false;
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Mailcow\Auth;
use RuntimeException;
/**
* The identity provider could not answer a login (unreachable, timeout, server error). No failed login:
* callers report a temporary failure and must not count it as an attempt for netfilter.
*/
class IdpUnavailable extends RuntimeException
{
}
@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace Mailcow\Auth\Service;
use LdapRecord\Auth\BindException;
use LdapRecord\Connection;
use LdapRecord\LdapRecordException;
use Mailcow\Auth\IdpUnavailable;
use Mailcow\Identity\Model\Settings;
use Mailcow\Identity\Service\LinkService;
use Mailcow\MailboxGroup\Service\TemplateService;
@@ -19,6 +23,37 @@ class IdpLoginService
{
}
/**
* Binds as the user to check the password, then again as the configured user. False for wrong credentials,
* IdpUnavailable when the server cannot answer (client side codes are negative: server down, timeout, ...).
*/
public function verifyLdapPassword(Connection $ldap, string $dn, string $password): bool
{
// an empty password would be an unauthenticated bind, which succeeds
if ($dn === '' || $password === '') {
return false;
}
$guard = $ldap->auth();
try {
$guard->bind($dn, $password);
$bound = true;
} catch (BindException $e) {
$code = $e->getDetailedError()?->getErrorCode();
if ($code !== null && ($code < 0 || in_array($code, [51, 52], true))) {
throw new IdpUnavailable('LDAP bind failed: ' . $e->getMessage(), 0, $e);
}
$bound = false;
} catch (LdapRecordException $e) {
throw new IdpUnavailable('LDAP bind failed: ' . $e->getMessage(), 0, $e);
}
try {
$guard->bindAsConfiguredUser();
} catch (LdapRecordException $e) {
// the login needs no further LDAP access
}
return $bound;
}
/** Template of the first attribute mapping matching one of the IdP attribute values, null if none */
public function mappedTemplate(mixed $values): ?string
{
+1 -2
View File
@@ -395,8 +395,7 @@
"iam_host": "Hostitel",
"iam_host_info": "Zadejte jeden či více hostitelů, oddělte čárkou.",
"iam_import_users": "Importovat uživatele",
"iam_auth_flow": "Proces autentizace",
"needs_restart": "potřebuje restart"
"iam_auth_flow": "Proces autentizace"
},
"danger": {
"access_denied": "Přístup odepřen nebo jsou neplatná data ve formuláři",
+1 -1
View File
@@ -352,7 +352,6 @@
"message": "Nachricht",
"message_size": "Nachrichtengröße",
"nexthop": "Next Hop",
"needs_restart": "benötigt Neustart",
"no": "&#10005;",
"no_active_bans": "Keine aktiven Banns",
"no_new_rows": "Keine weiteren Zeilen vorhanden",
@@ -560,6 +559,7 @@
"iam_sync_mode_invalid": "Ungültiger Sync-Modus",
"iam_sync_unavailable": "Der Identity Provider kann nicht synchronisiert werden (nur Keycloak und LDAP)",
"iam_test_connection": "Verbindung fehlgeschlagen",
"iam_unavailable": "Der Identity Provider ist nicht erreichbar, bitte später erneut versuchen.",
"imagick_exception": "Fataler Bildverarbeitungsfehler",
"img_dimensions_exceeded": "Grafik überschreitet die maximale Bildgröße",
"img_invalid": "Grafik konnte nicht validiert werden",
+1 -1
View File
@@ -359,7 +359,6 @@
"message": "Message",
"message_size": "Message size",
"nexthop": "Next hop",
"needs_restart": "needs restart",
"no": "&#10005;",
"no_active_bans": "No active bans",
"no_new_rows": "No further rows available",
@@ -561,6 +560,7 @@
"goto_invalid": "Goto address %s is invalid",
"ham_learn_error": "Ham learn error: %s",
"iam_test_connection": "Connection failed",
"iam_unavailable": "The identity provider is not reachable, please try again later.",
"imagick_exception": "Error: Imagick exception while reading image",
"img_dimensions_exceeded": "Image exceeds the maximum image size",
"img_invalid": "Cannot validate image file",
-1
View File
@@ -340,7 +340,6 @@
"iam_host_info": "Introduzca uno o más hosts de LDAP, separados por comas.",
"iam_import_users": "Importar usuarios",
"iam_mapping": "Equivalencias de atributos",
"needs_restart": "necesita reinicio",
"oauth2_apps": "Aplicaciones OAuth2",
"oauth2_add_client": "Añadir cliente OAuth2",
"oauth2_renew_secret": "Generar nuevo secreto de cliente",
-1
View File
@@ -395,7 +395,6 @@
"iam_host_info": "Saisissez un ou plusieurs hôtes LDAP, séparés par des virgules.",
"iam_import_users": "Importer des utilisateurs",
"filter": "Filtrer",
"needs_restart": "nécessite un redémarrage",
"iam": "Fournisseur d'identité"
},
"danger": {
+1 -2
View File
@@ -295,8 +295,7 @@
"validate_license_now": "GUID érvényesítése a licenszszerverrel szemben",
"yes": "&#10003;",
"success": "Siker",
"login_page": "Belépő oldal",
"needs_restart": "újraindítást igényel"
"login_page": "Belépő oldal"
},
"edit": {
"active": "Aktív",
-1
View File
@@ -376,7 +376,6 @@
"iam_version": "Versie",
"ignore_ssl_error": "Negeer SSL foutmeldingen",
"ip_check_opt_in": "Kies ervoor om de externe diensten <strong>ipv4.mailcow.email</strong> en <strong>ipv6.mailcow.email</strong> te gebruiken om externe IP-adressen te bepalen.",
"needs_restart": "vereist een herstart",
"no": "&#10005;",
"password_reset_info": "Als er geen herstel-e-mailadres is opgegeven, kan deze functie niet worden gebruikt.",
"password_reset_settings": "Instellingen voor wachtwoordherstel",
-1
View File
@@ -286,7 +286,6 @@
"merged_vars_hint": "Wyszarzone wiersze zostały połączone z <code>vars. (local.) inc.php</code> i nie można ich modyfikować.",
"message_size": "Rozmiar wiadomości",
"nexthop": "Następny hop",
"needs_restart": "potrzebny restart",
"no": "&#10005;",
"no_active_bans": "Brak aktywnych banów",
"no_new_rows": "Brak dostępnych kolejnych wierszy",
-1
View File
@@ -392,7 +392,6 @@
"iam_use_tls_info": "Se habilitar TLS, você deve usar a porta padrão para seu servidor LDAP (389). Portas SSL não podem ser usadas.",
"iam_version": "Versão",
"ignore_ssl_error": "Ignorar erros SSL",
"needs_restart": "precisa reiniciar",
"quicklink_text": "Mostrar ou ocultar links rápidos para outras páginas de login abaixo do formulário de login",
"task": "Tarefa",
"user_link": "Link do usuário",
+1 -2
View File
@@ -395,8 +395,7 @@
"iam_host": "Хост",
"iam_host_info": "Укажите один или несколько LDAP-хостов через запятую.",
"iam_import_users": "Импорт пользователей",
"admin_quicklink": "Скрыть ссылку на вход для администраторов",
"needs_restart": "необходим перезапуск"
"admin_quicklink": "Скрыть ссылку на вход для администраторов"
},
"danger": {
"access_denied": "Доступ запрещён, или указаны неверные данные",
-1
View File
@@ -397,7 +397,6 @@
"task": "Naloga",
"user_link": "Uporabniška povezava",
"iam_realm": "Realm",
"needs_restart": "potreben je ponovni zagon",
"ui_theme": "Tema",
"ui_theme_dark": "Vključuje različico s temnim načinom",
"ui_theme_default": "Privzeto",
-1
View File
@@ -266,7 +266,6 @@
"message": "Xabar",
"message_size": "Xabar oʻlchami",
"nexthop": "Keyingi nuqta",
"needs_restart": "qayta ishga tushirish kerak",
"no": "&#10005;",
"no_active_bans": "Faol bloklar yoʻq",
"no_new_rows": "Boshqa satrlar mavjud emas",
-1
View File
@@ -266,7 +266,6 @@
"message": "Tin nhắn",
"message_size": "Kích thước tin nhắn",
"nexthop": "Bước nhảy tiếp theo",
"needs_restart": "cần khởi động lại",
"no_active_bans": "Không có lệnh cấm đang hoạt động",
"no_new_rows": "Không có hàng nào khác",
"no_record": "Không có bản ghi",
-1
View File
@@ -395,7 +395,6 @@
"force_sso": "强制要求单点登录(SSO)",
"user_link": "自定义链接",
"app_hide": "在登入界面隐藏",
"needs_restart": "需要重启",
"iam_use_tls_info": "如果使用了 TLS,必须使用 LDAP 服务器的默认端口(389)。SSL 的端口不能使用。"
},
"danger": {
+1 -1
View File
@@ -119,7 +119,7 @@
{% if active_ban.queued_for_unban == 0 %}
<a data-action="edit_selected" data-item="{{ active_ban.network }}" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"unban"}' href="#">[{{ lang.admin.queue_unban }}]</a>
<a data-action="edit_selected" data-item="{{ active_ban.network }}" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"whitelist"}' href="#">[allowlist]</a>
<a data-action="edit_selected" data-item="{{ active_ban.network }}" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"blacklist"}' href="#">[denylist (<b>{{ lang.admin.needs_restart }}</b>)]</a>
<a data-action="edit_selected" data-item="{{ active_ban.network }}" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"blacklist"}' href="#">[denylist]</a>
{% else %}
<i>{{ lang.admin.unban_pending }}</i>
{% endif %}