From 44429fa0d0c4f8c93991740a192715af365f4803 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:29:55 +0100 Subject: [PATCH 01/26] [Web] App password and API adjustments --- data/web/inc/functions.app_passwd.inc.php | 9 +++++++++ data/web/inc/functions.auth.inc.php | 11 ++++++++--- data/web/json_api.php | 2 +- data/web/mobileconfig.php | 2 +- data/web/templates/user/tab-user-auth.twig | 2 ++ 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/data/web/inc/functions.app_passwd.inc.php b/data/web/inc/functions.app_passwd.inc.php index aacbb82df..085e22a8b 100644 --- a/data/web/inc/functions.app_passwd.inc.php +++ b/data/web/inc/functions.app_passwd.inc.php @@ -5,6 +5,15 @@ function app_passwd($_action, $_data = null) { $_data_log = $_data; !isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*'; !isset($_data_log['app_passwd2']) ?: $_data_log['app_passwd2'] = '*'; + if (in_array($_action, array('add', 'edit', 'delete')) && + (!isset($_SESSION['acl']['app_passwds']) || $_SESSION['acl']['app_passwds'] != "1")) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => 'access_denied' + ); + return false; + } if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) { if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) { $_SESSION['return'][] = array( diff --git a/data/web/inc/functions.auth.inc.php b/data/web/inc/functions.auth.inc.php index 87566af54..0899f1471 100644 --- a/data/web/inc/functions.auth.inc.php +++ b/data/web/inc/functions.auth.inc.php @@ -474,7 +474,7 @@ function apppass_login($user, $pass, $extra = null){ } // fetch app password data - $stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id` FROM `app_passwd` + $stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id`, `mailbox`.`attributes` as `mailbox_attributes` FROM `app_passwd` INNER JOIN `mailbox` ON `mailbox`.`username` = `app_passwd`.`mailbox` INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain` WHERE `mailbox`.`kind` NOT REGEXP 'location|thing|group' @@ -491,8 +491,13 @@ function apppass_login($user, $pass, $extra = null){ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as $row) { - if ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1'){ - continue; + if ($extra['service'] != 'NONE') { + $key = strtolower($extra['service']) . '_access'; + // app passwords never grant a protocol the mailbox itself is denied + $mailbox_attributes = json_decode($row['mailbox_attributes'], true); + if ($row[$key] != '1' || (isset($mailbox_attributes[$key]) && $mailbox_attributes[$key] != '1')) { + continue; + } } // verify password diff --git a/data/web/json_api.php b/data/web/json_api.php index 3a6ad2659..104355b74 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -107,7 +107,7 @@ if (isset($_GET['query'])) { switch ($action) { case "add": - if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username'])) { + if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username']) || !isset($_SESSION["mailcow_cc_username"])) { http_response_code(403); echo json_encode(array( 'type' => 'error', diff --git a/data/web/mobileconfig.php b/data/web/mobileconfig.php index c57c48857..02e0e9a56 100644 --- a/data/web/mobileconfig.php +++ b/data/web/mobileconfig.php @@ -39,7 +39,7 @@ if (isset($_GET['only_email'])) { $onlyEmailAccount = false; $description = 'IMAP, CalDAV, CardDAV'; } -if (isset($_GET['app_password'])) { +if (isset($_GET['app_password']) && ($_SESSION['acl']['app_passwds'] ?? '0') == '1') { $app_password = true; $description .= ' with application password'; diff --git a/data/web/templates/user/tab-user-auth.twig b/data/web/templates/user/tab-user-auth.twig index 171545d6c..9e713713e 100644 --- a/data/web/templates/user/tab-user-auth.twig +++ b/data/web/templates/user/tab-user-auth.twig @@ -77,6 +77,7 @@ {% endif %} + {% if acl.app_passwds == 1 %}
{{ lang.user.apple_connection_profile }}
{{ lang.user.with_app_password }}:
@@ -94,6 +95,7 @@ {% endif %}
+ {% endif %}
{{ lang.user.client_configuration }} From e986803980db8b29435a2ddba5d9760737a818e3 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:50:56 +0100 Subject: [PATCH 02/26] [Rspamd] Render domain footers without the template engine --- data/conf/rspamd/lua/rspamd.local.lua | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/data/conf/rspamd/lua/rspamd.local.lua b/data/conf/rspamd/lua/rspamd.local.lua index 5d7bcaaf3..e4e66bae6 100644 --- a/data/conf/rspamd/lua/rspamd.local.lua +++ b/data/conf/rspamd/lua/rspamd.local.lua @@ -765,13 +765,24 @@ rspamd_config:register_symbol({ end }) +-- Expand a domain footer: replace only "{= key =}" placeholders with their values. +-- Footer content is admin-controlled data, never a template, so it must not be run +-- through the jinja/lua engine (that would execute code in the shared rspamd worker). +-- Unknown placeholders render empty, matching the previous engine behaviour. +local function expand_footer(template, replacements) + return (template:gsub('{=%s*([%w_]+)%s*=}', function(key) + local value = replacements[key] + if value == nil then return '' end + return tostring(value) + end)) +end + rspamd_config:register_symbol({ name = 'MOO_FOOTER', type = 'prefilter', callback = function(task) local cjson = require "cjson" local lua_mime = require "lua_mime" - local lua_util = require "lua_util" local rspamd_logger = require "rspamd_logger" local rspamd_http = require "rspamd_http" local envfrom = task:get_from(1) @@ -844,10 +855,10 @@ rspamd_config:register_symbol({ end end if footer.html and footer.html ~= "" then - footer.html = lua_util.jinja_template(footer.html, replacements, true) + footer.html = expand_footer(footer.html, replacements) end if footer.plain and footer.plain ~= "" then - footer.plain = lua_util.jinja_template(footer.plain, replacements, true) + footer.plain = expand_footer(footer.plain, replacements) end -- add footer From d5e3de3ae88c1b8acddf63b8f4a1efdb8ae51830 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:04:45 +0100 Subject: [PATCH 03/26] [Web] Add TFA Ratelimit --- data/web/inc/functions.inc.php | 34 ++++++++++++++++++++++++++++++++++ data/web/inc/vars.inc.php | 6 ++++++ data/web/lang/lang.de-de.json | 1 + data/web/lang/lang.en-gb.json | 1 + 4 files changed, 42 insertions(+) diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 79bc97b24..ab149b60a 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -1857,6 +1857,40 @@ function get_tfa($username = null, $id = null) { } } function verify_tfa_login($username, $_data) { + global $redis; + global $TFA_MAX_FAILS; + global $TFA_FAIL_WINDOW; + + if (empty($username)) { + return false; + } + // Count failed second factors per account, so codes cannot be guessed from many addresses + $fails_key = 'TFA_FAILS_' . $username; + if (intval($redis->get($fails_key)) >= $TFA_MAX_FAILS) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $username, '*'), + 'msg' => 'tfa_login_locked' + ); + $verified = false; + } + else { + $verified = verify_tfa_token($username, $_data); + } + if ($verified !== true) { + // The window starts with the first failure and is not extended by further attempts + if ($redis->incr($fails_key) == 1) { + $redis->expire($fails_key, $TFA_FAIL_WINDOW); + } + // Same message format as failed passwords, so the default fail2ban rule counts it + error_log("mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']); + $redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']); + return false; + } + $redis->del($fails_key); + return true; +} +function verify_tfa_token($username, $_data) { global $pdo; global $yubi; global $u2f; diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php index 451704565..32f6f6645 100644 --- a/data/web/inc/vars.inc.php +++ b/data/web/inc/vars.inc.php @@ -169,6 +169,12 @@ $SESSION_NAME = "MCSESSID"; // Label for OTP devices $OTP_LABEL = "mailcow UI"; +// Failed second factor logins (TOTP, Yubico OTP, WebAuthn) of an account before its second factor is locked +$TFA_MAX_FAILS = 10; + +// Time (in s) from the first failed second factor login until the counter and the lock are reset +$TFA_FAIL_WINDOW = 900; + // How long to wait (in s) for cURL Docker requests $DOCKER_TIMEOUT = 60; diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index ca9a093b8..23dfd5c05 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -641,6 +641,7 @@ "temp_error": "Temporärer Fehler", "text_empty": "Text darf nicht leer sein", "tfa_token_invalid": "TFA-Token ungültig", + "tfa_login_locked": "Zu viele fehlgeschlagene Zwei-Faktor-Anmeldungen, bitte später erneut versuchen", "tfa_removal_blocked": "Zwei-Faktor-Authentifizierung kann nicht entfernt werden, da sie für Ihr Konto erforderlich ist.", "tls_policy_map_dest_invalid": "Ziel ist ungültig", "tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 7ba74740f..dc13af0a6 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -517,6 +517,7 @@ "group_send_as_invalid": "Send-as address %s is invalid or unknown", "iam_sync_mode_invalid": "Invalid sync mode", "iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)", + "tfa_login_locked": "Too many failed two-factor logins, please try again later", "tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.", "alias_domain_invalid": "Alias domain %s is invalid", "alias_empty": "Alias address must not be empty", From 9eb3a79f3bf1eae7dc757300c0ff52811d4d36de Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:16:00 +0100 Subject: [PATCH 04/26] [Web] Quarantine list adjustments --- data/web/inc/functions.quarantine.inc.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/data/web/inc/functions.quarantine.inc.php b/data/web/inc/functions.quarantine.inc.php index c89b3e408..55674e528 100644 --- a/data/web/inc/functions.quarantine.inc.php +++ b/data/web/inc/functions.quarantine.inc.php @@ -767,8 +767,9 @@ function quarantine($_action, $_data = null) { else { $domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains')); foreach ($domains as $domain) { - $stmt = $pdo->prepare('SELECT `id`, `qid`, `subject`, LOCATE("VIRUS_FOUND", `symbols`) AS `virus_flag`, `score`, `rcpt`, `sender`, `action`, UNIX_TIMESTAMP(`created`) AS `created`, `notified` FROM `quarantine` WHERE `rcpt` REGEXP :domain'); - $stmt->execute(array(':domain' => '@' . $domain . '$')); + // Compare the domain part exactly, a regex would treat dots in the domain as wildcards + $stmt = $pdo->prepare('SELECT `id`, `qid`, `subject`, LOCATE("VIRUS_FOUND", `symbols`) AS `virus_flag`, `score`, `rcpt`, `sender`, `action`, UNIX_TIMESTAMP(`created`) AS `created`, `notified` FROM `quarantine` WHERE SUBSTRING_INDEX(`rcpt`, "@", -1) = :domain'); + $stmt->execute(array(':domain' => $domain)); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); while($row = array_shift($rows)) { $q_meta[] = $row; From 1b6fec6a892f06c7a1647fff72cf2fef5e7a1027 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:37 +0100 Subject: [PATCH 05/26] [Web] Hide the sync job form without the syncjobs ACL --- data/web/edit.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/data/web/edit.php b/data/web/edit.php index eceaf6f75..51d88a12d 100644 --- a/data/web/edit.php +++ b/data/web/edit.php @@ -227,7 +227,8 @@ if (isset($_SESSION['mailcow_cc_role'])) { if (isset($_GET['syncjob']) && is_numeric($_GET['syncjob'])) { $id = $_GET["syncjob"]; - $result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id); + // Without the syncjobs ACL a job cannot be saved, so do not show the form + $result = (($_SESSION['acl']['syncjobs'] ?? '0') == '1') ? \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id) : []; $template = 'edit/syncjob.twig'; } elseif (isset($_GET['syncjob_source']) && From 085d10118ddb8f23a24d98d3951cee6576f9d1e0 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:36:16 +0100 Subject: [PATCH 06/26] [Nginx][Web] Client address and autodiscover adjustments --- data/conf/nginx/templates/nginx.conf.j2 | 7 ++ .../nginx/templates/sites-default.conf.j2 | 3 +- data/web/autodiscover.php | 85 +++++++++---------- 3 files changed, 51 insertions(+), 44 deletions(-) diff --git a/data/conf/nginx/templates/nginx.conf.j2 b/data/conf/nginx/templates/nginx.conf.j2 index 861c7e560..d97bb8b99 100644 --- a/data/conf/nginx/templates/nginx.conf.j2 +++ b/data/conf/nginx/templates/nginx.conf.j2 @@ -47,6 +47,13 @@ http { default ""; } + # Client address for PHP: on the internal sogo-auth listener nginx itself is the client, + # the real address comes from the X-Real-IP header set by /sogo-auth-verify + map $sogo_auth_internal $mailcow_client_ip { + "1" $http_x_real_ip; + default $remote_addr; + } + {% if HTTP_REDIRECT %} # HTTP to HTTPS redirect server { diff --git a/data/conf/nginx/templates/sites-default.conf.j2 b/data/conf/nginx/templates/sites-default.conf.j2 index 842c50a54..9cd60a343 100644 --- a/data/conf/nginx/templates/sites-default.conf.j2 +++ b/data/conf/nginx/templates/sites-default.conf.j2 @@ -116,7 +116,8 @@ location ~ \.php$ { include /etc/nginx/fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; - fastcgi_param HTTP_X_REAL_IP $remote_addr; + fastcgi_param REMOTE_ADDR $mailcow_client_ip; + fastcgi_param HTTP_X_REAL_IP $mailcow_client_ip; # trusted internal-auth marker; empty for external clients (see nginx.conf map) fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal; fastcgi_read_timeout 3600; diff --git a/data/web/autodiscover.php b/data/web/autodiscover.php index 3e31dc3dd..e2d70bbae 100644 --- a/data/web/autodiscover.php +++ b/data/web/autodiscover.php @@ -144,41 +144,15 @@ try { } $username = trim((string)$email); -try { - $stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox` - INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain` - WHERE `mailbox`.`username` = :username - AND `mailbox`.`active` = '1' - AND `domain`.`active` = '1'"); - $stmt->execute(array(':username' => $username)); - $MailboxData = $stmt->fetch(PDO::FETCH_ASSOC); -} -catch(PDOException $e) { - // Database error - return error response with complete XML - list($usec, $sec) = explode(' ', microtime()); -?> - - - 500 - Database Error - - - - - time(), "ua" => $_SERVER['HTTP_USER_AGENT'], - "user" => $email, + "user" => "none", "ip" => $_SERVER['REMOTE_ADDR'], - "service" => "Error: mailbox not found or inactive" + "service" => "Error: invalid email address in request" ) ); $redis->lPush('AUTODISCOVER_LOG', $json); @@ -202,13 +176,33 @@ if (empty($MailboxData)) { prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox` + INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain` + WHERE `mailbox`.`username` = :username + AND `mailbox`.`active` = '1' + AND `domain`.`active` = '1'"); + $stmt->execute(array(':username' => $username)); + $MailboxData = $stmt->fetch(PDO::FETCH_ASSOC); +} +catch(PDOException $e) { + // Database error - return error response with complete XML + list($usec, $sec) = explode(' ', microtime()); +?> + + + 500 + Database Error + + + + + $_SERVER['HTTP_USER_AGENT'], "user" => $email, "ip" => $_SERVER['REMOTE_ADDR'], - "service" => $autodiscover_config['autodiscoverType'] + // The answer is the same, only the log tells unknown or inactive mailboxes apart + "service" => (empty($MailboxData)) ? "Error: mailbox not found or inactive" : $autodiscover_config['autodiscoverType'] ) ); $redis->lPush('AUTODISCOVER_LOG', $json); $redis->lTrim('AUTODISCOVER_LOG', 0, 100); + if (empty($MailboxData)) { + $redis->publish("F2B_CHANNEL", "Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']); + error_log("Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']); + } } catch (RedisException $e) { $_SESSION['return'][] = array( @@ -243,7 +242,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') { off - + off on on @@ -253,7 +252,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') { off - + off on on @@ -265,15 +264,15 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') { ?> CalDAV - https:///SOGo/dav// + https:///SOGo/dav// off - + CardDAV - https:///SOGo/dav// + https:///SOGo/dav// off - + en:en - + From 4c7e2ec0365ed8ee1f69b9f56887db3d18e0f248 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:54:26 +0100 Subject: [PATCH 07/26] [Nginx] Limit request body size, configurable via NGINX_MAX_BODY_SIZE --- _modules/scripts/new_options.sh | 6 ++++++ data/Dockerfiles/nginx/bootstrap.py | 7 +++++++ data/conf/nginx/templates/sites-default.conf.j2 | 9 ++++++--- docker-compose.yml | 3 ++- generate_config.sh | 4 ++++ 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/_modules/scripts/new_options.sh b/_modules/scripts/new_options.sh index 840700148..7d67799ba 100644 --- a/_modules/scripts/new_options.sh +++ b/_modules/scripts/new_options.sh @@ -60,6 +60,7 @@ adapt_new_options() { "ACME_DNS_CHALLENGE" "ACME_DNS_PROVIDER" "ACME_ACCOUNT_EMAIL" + "NGINX_MAX_BODY_SIZE" ) sed -i --follow-symlinks '$a\' mailcow.conf @@ -309,6 +310,11 @@ adapt_new_options() { echo '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> mailcow.conf ;; + NGINX_MAX_BODY_SIZE) + echo '# Maximum request size for SOGo webmail, CalDAV/CardDAV and ActiveSync, e.g. 128m or 1g - 0 means unlimited' >> mailcow.conf + echo '# Keep it above the message size limit of Postfix (100 MB by default). The mailcow UI and API stay limited to 64m.' >> mailcow.conf + echo 'NGINX_MAX_BODY_SIZE=128m' >> mailcow.conf + ;; *) echo "${option}=" >> mailcow.conf ;; diff --git a/data/Dockerfiles/nginx/bootstrap.py b/data/Dockerfiles/nginx/bootstrap.py index 97ce7a4e1..b48b26f50 100644 --- a/data/Dockerfiles/nginx/bootstrap.py +++ b/data/Dockerfiles/nginx/bootstrap.py @@ -1,4 +1,5 @@ import os +import re import subprocess from jinja2 import Environment, FileSystemLoader @@ -44,6 +45,11 @@ def prepare_template_vars(): ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1") additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "") trusted_proxies = os.getenv("TRUSTED_PROXIES", "") + # nginx size syntax, invalid values fall back to the default + max_body_size = os.getenv("NGINX_MAX_BODY_SIZE", "128m").strip() + if not re.fullmatch(r'[0-9]+[kKmMgG]?', max_body_size): + print(f"Invalid NGINX_MAX_BODY_SIZE '{max_body_size}', using 128m") + max_body_size = "128m" template_vars = { 'IPV4_NETWORK': ipv4_network, @@ -60,6 +66,7 @@ def prepare_template_vars(): 'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"), 'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false", 'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"), + 'NGINX_MAX_BODY_SIZE': max_body_size, } ssl_dir = '/etc/ssl/mail/' diff --git a/data/conf/nginx/templates/sites-default.conf.j2 b/data/conf/nginx/templates/sites-default.conf.j2 index 9cd60a343..2082c6822 100644 --- a/data/conf/nginx/templates/sites-default.conf.j2 +++ b/data/conf/nginx/templates/sites-default.conf.j2 @@ -22,7 +22,8 @@ add_header Referrer-Policy strict-origin; index index.php index.html; -client_max_body_size 0; +# PHP accepts no larger requests (post_max_size), SOGo webmail, CalDAV/CardDAV and ActiveSync use NGINX_MAX_BODY_SIZE +client_max_body_size 64m; gzip on; gzip_disable "msie6"; @@ -182,6 +183,8 @@ location /sogo-auth-verify { proxy_set_header Content-Length ""; proxy_pass http://127.0.0.1:65510/sogo-auth; proxy_pass_request_body off; + # The subrequest checks the size of the original request against this limit, the body is never read here + client_max_body_size {{ NGINX_MAX_BODY_SIZE }}; } location ^~ /Microsoft-Server-ActiveSync { @@ -205,7 +208,7 @@ location ^~ /Microsoft-Server-ActiveSync { proxy_busy_buffers_size 512k; proxy_set_header Host $http_host; client_body_buffer_size 512k; - client_max_body_size 0; + client_max_body_size {{ NGINX_MAX_BODY_SIZE }}; } location ^~ /SOGo { @@ -256,7 +259,7 @@ location ^~ /SOGo { proxy_send_timeout 3600; proxy_read_timeout 3600; client_body_buffer_size 128k; - client_max_body_size 0; + client_max_body_size {{ NGINX_MAX_BODY_SIZE }}; break; } diff --git a/docker-compose.yml b/docker-compose.yml index c77c7baaa..4e2598ed8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -427,7 +427,7 @@ services: - php-fpm-mailcow - sogo-mailcow - rspamd-mailcow - image: ghcr.io/mailcow/nginx:1.30.6-1 + image: ghcr.io/mailcow/nginx:1.30.6-2 dns: - ${IPV4_NETWORK:-172.22.1}.254 environment: @@ -447,6 +447,7 @@ services: - IPV4_NETWORK=${IPV4_NETWORK:-172.22.1} - NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n} - TRUSTED_PROXIES=${TRUSTED_PROXIES:-} + - NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE:-128m} volumes: - ./data/web:/web:ro,z - ./data/conf/rspamd/dynmaps:/dynmaps:ro,z diff --git a/generate_config.sh b/generate_config.sh index fe525b91b..5d76e5ea5 100755 --- a/generate_config.sh +++ b/generate_config.sh @@ -215,6 +215,10 @@ HTTPS_BIND= # Redirect HTTP connections to HTTPS - y/n HTTP_REDIRECT=y +# Maximum request size for SOGo webmail, CalDAV/CardDAV and ActiveSync, e.g. 128m or 1g - 0 means unlimited +# Keep it above the message size limit of Postfix (100 MB by default). The mailcow UI and API stay limited to 64m. +NGINX_MAX_BODY_SIZE=128m + # ------------------------------ # Other bindings # ------------------------------ From a21604cd7b9578366dd7d1eb0e3088d78b6e3017 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:06:17 +0100 Subject: [PATCH 08/26] [Web][Rspamd] Quarantine link adjustments --- data/conf/rspamd/meta_exporter/pipe.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/data/conf/rspamd/meta_exporter/pipe.php b/data/conf/rspamd/meta_exporter/pipe.php index ab0d1faa0..c0e056c6a 100644 --- a/data/conf/rspamd/meta_exporter/pipe.php +++ b/data/conf/rspamd/meta_exporter/pipe.php @@ -216,8 +216,9 @@ foreach ($rcpts as $rcpt) { foreach ($rcpt_final_mailboxes as $rcpt_final) { error_log("QUARANTINE: quarantine pipe: processing quarantine message for rcpt " . $rcpt_final . PHP_EOL); try { - $stmt = $pdo->prepare("INSERT INTO `quarantine` (`qid`, `subject`, `score`, `sender`, `rcpt`, `symbols`, `user`, `ip`, `msg`, `action`, `fuzzy_hashes`) - VALUES (:qid, :subject, :score, :sender, :rcpt, :symbols, :user, :ip, :msg, :action, :fuzzy_hashes)"); + // qhash is the secret of the release/delete links in notifications, so it must not be derivable + $stmt = $pdo->prepare("INSERT INTO `quarantine` (`qid`, `subject`, `score`, `sender`, `rcpt`, `symbols`, `user`, `ip`, `msg`, `action`, `fuzzy_hashes`, `qhash`) + VALUES (:qid, :subject, :score, :sender, :rcpt, :symbols, :user, :ip, :msg, :action, :fuzzy_hashes, :qhash)"); $stmt->execute(array( ':qid' => $qid, ':subject' => $subject, @@ -229,11 +230,9 @@ foreach ($rcpt_final_mailboxes as $rcpt_final) { ':ip' => $ip, ':msg' => $raw_data, ':action' => $action, - ':fuzzy_hashes' => $fuzzy + ':fuzzy_hashes' => $fuzzy, + ':qhash' => bin2hex(random_bytes(32)) )); - $lastId = $pdo->lastInsertId(); - $stmt_update = $pdo->prepare("UPDATE `quarantine` SET `qhash` = SHA2(CONCAT(`id`, `qid`), 256) WHERE `id` = :id"); - $stmt_update->execute(array(':id' => $lastId)); $stmt = $pdo->prepare('DELETE FROM `quarantine` WHERE `rcpt` = :rcpt AND `id` NOT IN ( SELECT `id` FROM ( From 510674ee6766a168861e2f3841f7920916fcbd54 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:11:25 +0100 Subject: [PATCH 09/26] [Web] Pending account setup adjustments --- data/web/inc/functions.admin.inc.php | 9 +++++++++ data/web/json_api.php | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/data/web/inc/functions.admin.inc.php b/data/web/inc/functions.admin.inc.php index 69f1b7e56..47be36cc7 100644 --- a/data/web/inc/functions.admin.inc.php +++ b/data/web/inc/functions.admin.inc.php @@ -74,6 +74,15 @@ function admin($_action, $_data = null) { $usernames = $_data['username']; } foreach ($usernames as $username) { + // A pending password update only allows changing the own password + if (!empty($_SESSION['pending_pw_update']) && $username != $_SESSION['mailcow_cc_username']) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => 'access_denied' + ); + continue; + } $is_now = admin('details', $username); if (!empty($is_now)) { $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active']; diff --git a/data/web/json_api.php b/data/web/json_api.php index 104355b74..9e147a18f 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -105,6 +105,24 @@ if (isset($_GET['query'])) { 'msg' => 'Cannot find attributes in post data' )); + // A login with pending 2FA setup or password update may only use the calls of these steps + if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) { + if (!empty($_SESSION['pending_tfa_setup'])) { + $pending_allowed = array('get/passwordpolicy', 'get/webauthn-tfa-registration', 'add/webauthn-tfa-registration'); + } + else { + $pending_allowed = array('get/passwordpolicy', 'edit/self', 'edit/admin'); + } + if (!in_array($action . '/' . $category, $pending_allowed)) { + http_response_code(403); + echo json_encode(array( + 'type' => 'error', + 'msg' => 'API access denied until the pending account setup is finished' + )); + exit(); + } + } + switch ($action) { case "add": if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username']) || !isset($_SESSION["mailcow_cc_username"])) { From 1cb0656924cd12a4eb2919f4d3358cab8513bd50 Mon Sep 17 00:00:00 2001 From: milkmaker Date: Sun, 1 Nov 2026 21:43:41 +0100 Subject: [PATCH 10/26] update postscreen_access.cidr (#7584) --- data/conf/postfix/postscreen_access.cidr | 35 +++++++++--------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/data/conf/postfix/postscreen_access.cidr b/data/conf/postfix/postscreen_access.cidr index b479644db..dfcde6662 100644 --- a/data/conf/postfix/postscreen_access.cidr +++ b/data/conf/postfix/postscreen_access.cidr @@ -1,6 +1,6 @@ -# Whitelist generated by Postwhite v3.4 on Thu Oct 1 00:35:20 UTC 2026 +# Whitelist generated by Postwhite v3.4 on Sun Nov 1 00:23:09 UTC 2026 # https://github.com/stevejenkins/postwhite/ -# 2246 total rules +# 2237 total rules 2a00:1450:4000::/36 permit 2a00:1450:4864::/56 permit 2a01:111:f400::/48 permit @@ -197,6 +197,9 @@ 44.246.68.102 permit 44.246.77.92 permit 45.14.148.0/22 permit +45.128.172.192/27 permit +45.128.172.224/28 permit +45.128.174.0/25 permit 45.143.132.0/24 permit 45.143.133.0/24 permit 45.143.134.0/24 permit @@ -672,7 +675,6 @@ 85.215.255.47 permit 85.215.255.48 permit 85.215.255.49 permit -86.61.88.25 permit 87.238.80.0/21 permit 87.248.103.12 permit 87.248.103.21 permit @@ -1345,11 +1347,11 @@ 107.20.210.250 permit 107.22.191.150 permit 108.174.0.0/24 permit -108.174.0.215 permit +108.174.0.93 permit 108.174.3.0/24 permit -108.174.3.215 permit +108.174.3.93 permit 108.174.6.0/24 permit -108.174.6.215 permit +108.174.6.93 permit 108.175.18.45 permit 108.175.30.45 permit 108.179.144.0/20 permit @@ -1532,11 +1534,6 @@ 147.154.63.0/24 permit 147.154.126.0/24 permit 147.154.191.0/24 permit -147.243.1.47 permit -147.243.1.48 permit -147.243.1.153 permit -147.243.128.24 permit -147.243.128.26 permit 148.105.0.0/16 permit 148.105.8.0/21 permit 148.116.32.128/25 permit @@ -1546,7 +1543,7 @@ 149.97.173.180 permit 149.118.160.128/25 permit 150.136.21.199 permit -150.171.109.178 permit +150.171.109.66 permit 150.230.98.160 permit 151.145.38.14 permit 152.67.105.195 permit @@ -1562,7 +1559,6 @@ 155.248.237.141 permit 157.56.120.128/26 permit 157.58.30.128/25 permit -157.58.196.96/29 permit 157.58.249.3 permit 157.137.32.128/25 permit 157.137.96.128/25 permit @@ -1651,7 +1647,6 @@ 167.89.75.126 permit 167.89.75.136 permit 167.89.75.164 permit -167.220.67.232/29 permit 168.107.248.128/25 permit 168.110.160.128/25 permit 168.110.248.128/25 permit @@ -1979,8 +1974,6 @@ 207.46.22.35 permit 207.46.50.72 permit 207.46.50.82 permit -207.46.52.71 permit -207.46.52.79 permit 207.67.38.0/24 permit 207.67.98.192/27 permit 207.97.204.96/29 permit @@ -2138,8 +2131,6 @@ 212.227.126.227 permit 213.199.128.139 permit 213.199.128.145 permit -213.199.138.181 permit -213.199.138.191 permit 216.17.150.242 permit 216.17.150.251 permit 216.24.224.0/20 permit @@ -2228,23 +2219,23 @@ 2001:748:400:3301::4 permit 2404:6800:4000::/36 permit 2404:6800:4864::/56 permit -2603:1061:14:1d0::1 permit +2603:1061:14:41::1 permit 2607:13c0:0001:0000:0000:0000:0000:7000/116 permit 2607:13c0:0002:0000:0000:0000:0000:1000/116 permit 2607:13c0:0004:0000:0000:0000:0000:0000/116 permit 2607:f8b0:4000::/36 permit 2607:f8b0:4864::/56 permit 2620:109:c003:104::/64 permit -2620:109:c003:104::215 permit +2620:109:c003:104::5d permit 2620:109:c006:104::/64 permit -2620:109:c006:104::215 permit +2620:109:c006:104::5d permit 2620:109:c00d:104::/64 permit 2620:10d:c090:400::8:1 permit 2620:10d:c091:400::8:1 permit 2620:10d:c09b:400::8:1 permit 2620:10d:c09c:400::8:1 permit 2620:119:50c0:207::/64 permit -2620:119:50c0:207::215 permit +2620:119:50c0:207::5d permit 2800:3f0:4000::/36 permit 2800:3f0:4864::/56 permit 49.12.4.251 permit # checks.mailcow.email From a1cbd51d70ce9e74f2f1fa75a8a5adbdb889e33e Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:49:03 +0100 Subject: [PATCH 11/26] [Dovecot][Web] imapsync: restrict custom parameters and source hosts, verify certificates --- data/Dockerfiles/dovecot/imapsync_runner.py | 43 +++++++++++++++++-- .../mailcow/Imapsync/Model/CustomParams.php | 12 +++++- data/web/inc/vars.inc.php | 5 +-- docker-compose.yml | 3 +- tests/php/Imapsync/Model/CustomParamsTest.php | 10 +++++ 5 files changed, 63 insertions(+), 10 deletions(-) diff --git a/data/Dockerfiles/dovecot/imapsync_runner.py b/data/Dockerfiles/dovecot/imapsync_runner.py index 09fa1eda6..53dca5ea6 100755 --- a/data/Dockerfiles/dovecot/imapsync_runner.py +++ b/data/Dockerfiles/dovecot/imapsync_runner.py @@ -1,10 +1,12 @@ #!/usr/bin/python3 import fcntl +import ipaddress import json import os import re import signal +import socket import subprocess import sys import tempfile @@ -18,6 +20,15 @@ max_output = 16 * 1024 * 1024 lock_file = run_dir + '/imapsync_busy.lock' # An OAuth run needs a token valid this long at start (half the lifetime for short-lived tokens) token_min_valid = 900 +# Custom params never passed, also when stored before (same list as CustomParams::BLOCKED in the web UI) +blocked_params = {'abort', 'delete2foldersbutnot', 'delete2foldersonly', 'logdir', 'logfile', 'maxlinelengthcmd', + 'oauthrefreshcmd1', 'oauthrefreshcmd2', 'passfile1', 'passfile2', 'pidfile', 'pipemess', 'regexflag', 'regexmess', + 'regextrans2', 'showpasswords', 'tests', 'testsdebug', 'testslive', 'testslive6', 'testsunit', 'tmpdir'} +# imapsync only gets these variables, not the secrets of the container environment +job_env_keys = ('PATH', 'LANG', 'LC_ALL', 'TZ') +# A source must not point to mailcow itself (internal Docker network), host1 is checked before each run +internal_nets = [ipaddress.ip_network(os.environ.get('IPV4_NETWORK', '172.22.1') + '.0/24', strict=False), + ipaddress.ip_network(os.environ.get('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64'), strict=False)] # Refreshed tokens are written to the token files of running jobs this often, imapsync re-reads them on reconnect token_sync_interval = 60 @@ -106,6 +117,20 @@ def defer_job(job_id, text, exit_status): (text, exit_status, job_id)) deferred.add(job_id) +# True when host resolves to loopback, link-local or the internal network; unknown hosts are left to imapsync +def internal_host(host): + try: + addrs = {a[4][0] for a in socket.getaddrinfo(host, None)} + except (socket.gaierror, UnicodeError): + return False + for addr in addrs: + ip = ipaddress.ip_address(addr.split('%')[0]) + if ip.version == 6 and ip.ipv4_mapped: + ip = ip.ipv4_mapped + if ip.is_loopback or ip.is_unspecified or ip.is_link_local or any(ip in net for net in internal_nets): + return True + return False + def oauth_token(j): if j['oauth_flow'] == 'authorization_code': return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {} @@ -150,6 +175,11 @@ def prepare_job(j, global_bw, files): f.write(content) return path + if internal_host(j['host1']): + finish_job(j['id'], 'Host1 %s is an address of mailcow itself, the sync job does not connect to it\n' % j['host1'], 0, + 'EXIT_CONNECTION_FAILURE_HOST1') + return None + auth_type = j['auth_type'] or 'PLAIN' oauth = None if auth_type == 'XOAUTH2': @@ -174,7 +204,11 @@ def prepare_job(j, global_bw, files): except ValueError: pairs = [] for p in pairs if isinstance(pairs, list) else []: - if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']): + if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']) \ + or p['o'] in blocked_params: + continue + # The certificate check protects the OAuth token of the source, a job must not turn it off + if oauth and p['o'] == 'sslargs1': continue value = '' if p.get('v') is None else str(p['v']) custom_params.append('--' + p['o'] if value == '' else '--' + p['o'] + '=' + value) @@ -205,7 +239,8 @@ def prepare_job(j, global_bw, files): cmd.append(opt) enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1']) if enc1: - cmd.append(enc1) + # Check the certificate of the source (imapsync does not by default), custom sslargs1 can turn it off + cmd += [enc1, '--sslargs1', 'SSL_verify_mode=1'] cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])] cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')] if j['dry'] == 1: @@ -216,8 +251,10 @@ def prepare_job(j, global_bw, files): # Worker: imapsync with output to a file, no inherited fds (DB socket, lock) def start_job(job): + env = {k: os.environ[k] for k in job_env_keys if k in os.environ} + env['HOME'] = run_dir with open(job['outfile'], 'wb') as out: - return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT) + return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT, env=env) # Store the result of a finished worker and remove its temp files def reap_job(job): diff --git a/data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php b/data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php index 4a900e855..7f31d67d7 100644 --- a/data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php +++ b/data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php @@ -19,6 +19,14 @@ class CustomParams { private const MAX_PAIRS = 100; private const MAX_VALUE_LENGTH = 1000; + /** + * Never allowed, even when configured: code or command execution, local files, the environment + * of the container (tests*), passwords in the output and stopping other runs (abort). + * The runner (dovecot/imapsync_runner.py) skips the same options. + */ + public const BLOCKED = ['abort', 'delete2foldersbutnot', 'delete2foldersonly', 'logdir', 'logfile', 'maxlinelengthcmd', + 'oauthrefreshcmd1', 'oauthrefreshcmd2', 'passfile1', 'passfile2', 'pidfile', 'pipemess', 'regexflag', 'regexmess', + 'regextrans2', 'showpasswords', 'tests', 'testsdebug', 'testslive', 'testslive6', 'testsunit', 'tmpdir']; private static bool $warnedOldFormat = false; @@ -57,7 +65,7 @@ class CustomParams /** * The effective allowlist: $IMAPSYNC_OPTIONS (after vars.local.inc.php) without the old - * 'whitelist' key and malformed entries; the defaults when nothing valid is left. + * 'whitelist' key, malformed entries and BLOCKED options; the defaults when nothing valid is left. */ public static function allowlist(array $configured, array $defaults): array { @@ -73,7 +81,7 @@ class CustomParams self::$warnedOldFormat = true; error_log("mailcow: \$IMAPSYNC_OPTIONS['whitelist'] (old format) is ignored, use 'option' => true|false|'regex' (see data/web/inc/vars.inc.php)"); } - return empty($allowlist) ? $defaults : $allowlist; + return array_diff_key(empty($allowlist) ? $defaults : $allowlist, array_flip(self::BLOCKED)); } /** The value to store for one option; throws when the allowlist rule is violated */ diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php index 451704565..3892ae315 100644 --- a/data/web/inc/vars.inc.php +++ b/data/web/inc/vars.inc.php @@ -296,8 +296,8 @@ $RSPAMD_MAPS = array( // 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 options taking local file paths (e.g. 'logfile', 'logdir', 'pidfile', 'passfile1', 'sslargs1' with SSL_ca_file) +// These, the tests* options (print the environment), 'showpasswords' and 'abort' (stops other runs) are also blocked in code $IMAPSYNC_OPTIONS_DEFAULT = array( - 'abort' => false, 'authmd51' => false, 'authuser1' => true, 'debug' => false, @@ -373,9 +373,6 @@ $IMAPSYNC_OPTIONS_DEFAULT = array( 'syncduplicates' => false, 'syncinternaldates' => false, 'synclabels' => false, - 'tests' => false, - 'testslive' => false, - 'testslive6' => false, 'truncmess' => true, 'usecache' => false, 'useheader' => true, diff --git a/docker-compose.yml b/docker-compose.yml index c77c7baaa..81bdd7468 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -259,7 +259,7 @@ services: - sogo dovecot-mailcow: - image: ghcr.io/mailcow/dovecot:2.3.21.1-3 + image: ghcr.io/mailcow/dovecot:2.3.21.1-4 depends_on: - mysql-mailcow - netfilter-mailcow @@ -295,6 +295,7 @@ services: - MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} - MAILCOW_PASS_SCHEME=${MAILCOW_PASS_SCHEME:-BLF-CRYPT} - IPV4_NETWORK=${IPV4_NETWORK:-172.22.1} + - IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64} - ALLOW_ADMIN_EMAIL_LOGIN=${ALLOW_ADMIN_EMAIL_LOGIN:-n} - MAILDIR_GC_TIME=${MAILDIR_GC_TIME:-7200} - ACL_ANYONE=${ACL_ANYONE:-disallow} diff --git a/tests/php/Imapsync/Model/CustomParamsTest.php b/tests/php/Imapsync/Model/CustomParamsTest.php index 924db327b..9ca43eace 100644 --- a/tests/php/Imapsync/Model/CustomParamsTest.php +++ b/tests/php/Imapsync/Model/CustomParamsTest.php @@ -132,6 +132,16 @@ class CustomParamsTest extends TestCase $this->assertSame(['default' => true], CustomParams::allowlist(['bad name' => true], ['default' => true])); } + public function testBlockedOptionsAreNeverAllowed(): void + { + $allowlist = CustomParams::allowlist(['tests' => false, 'pipemess' => true, 'folder' => true], ['abort' => false]); + $this->assertSame(['folder' => true], $allowlist); + $this->assertSame([], CustomParams::allowlist([], ['abort' => false, 'showpasswords' => false])); + + $this->expectException(UserError::class); + CustomParams::toJson([['o' => '--tests', 'v' => '']], $allowlist); + } + public function testOldWhitelistFormatIsIgnored(): void { $errorLog = ini_set('error_log', '/dev/null'); From 41467dc47fa3a5403c7050cd2e8b1496ba856fb3 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:50:24 +0100 Subject: [PATCH 12/26] [Web] imapsync: change the server of a sync source only with access to all its sync jobs --- .../Imapsync/Repository/SourceRepository.php | 6 +++++ .../Imapsync/Service/SourceService.php | 24 ++++++++++++++++++- data/web/lang/lang.de-de.json | 1 + data/web/lang/lang.en-gb.json | 1 + .../Imapsync/Service/SourceServiceTest.php | 14 +++++++++++ 5 files changed, 45 insertions(+), 1 deletion(-) diff --git a/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php b/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php index b35397013..6e6b37dfe 100644 --- a/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php +++ b/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php @@ -48,6 +48,12 @@ class SourceRepository extends Repository return (bool)$this->query("SELECT 1 FROM `imapsync` WHERE `source_id` = :id LIMIT 1", ['id' => $id])->fetchColumn(); } + /** @return string[] mailboxes whose sync jobs use the source */ + public function jobMailboxes(int $id): array + { + return $this->query("SELECT DISTINCT `user2` FROM `imapsync` WHERE `source_id` = :id", ['id' => $id])->fetchAll(PDO::FETCH_COLUMN); + } + public function isSuperAdmin(string $username): bool { return (bool)$this->query("SELECT 1 FROM `admin` WHERE `username` = :username AND `superadmin` = '1'", diff --git a/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php b/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php index c2a9b6d6d..2aa01b633 100644 --- a/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php +++ b/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php @@ -121,7 +121,11 @@ class SourceService if ($writeTargets) { $this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []); } - $this->repo->update($source, $writeTargets, self::connection($current) !== self::connection($source)); + $connectionChanged = self::connection($current) !== self::connection($source); + if ($connectionChanged) { + $this->requireAllJobsAccessible($source); + } + $this->repo->update($source, $writeTargets, $connectionChanged); $this->fetchToken($source); return $source; } @@ -200,6 +204,24 @@ class SourceService return $this->context->hasAcl('syncjobs') || $this->context->isAdmin(); } + /** + * The connection of a source only changes when the caller may access every mailbox whose sync jobs use it, + * their stored passwords would go to the new server. + * + * @throws UserError imapsync_source_used_by_others + */ + private function requireAllJobsAccessible(Source $source): void + { + if ($this->context->hasAdminRights()) { + return; + } + foreach ($this->repo->jobMailboxes((int)$source->id) as $mailbox) { + if (!$this->context->canAccessMailbox($mailbox)) { + throw new UserError('imapsync_source_used_by_others', [htmlspecialchars($source->name)]); + } + } + } + /** @throws AccessDenied when the caller cannot see or edit the source */ private function editable(int $id): Source { diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index ca9a093b8..6e4e5c357 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -666,6 +666,7 @@ "template_name_invalid": "Name der Vorlage ungültig", "required_data_missing": "Die benötigten Daten: %s fehlen", "imapsync_source_in_use": "Sync-Quelle %s wird noch von einem Sync-Job verwendet und kann nicht gelöscht werden", + "imapsync_source_used_by_others": "Der Server der Sync-Quelle %s kann nicht geändert werden, Sync-Jobs anderer Postfächer verwenden sie", "imapsync_source_token_refresh_failed": "OAuth-Token-Erneuerung für %s fehlgeschlagen", "imapsync_source_scope_empty": "Für die gewählte Sichtbarkeit mindestens eine Domain wählen", "imapsync_source_unavailable": "Die gewählte Sync-Quelle ist nicht verfügbar", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 7ba74740f..cd5848d03 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -666,6 +666,7 @@ "version_invalid": "Version %s is invalid", "yotp_verification_failed": "Yubico OTP verification failed: %s", "imapsync_source_in_use": "Sync source %s is still used by a sync job and cannot be deleted", + "imapsync_source_used_by_others": "The server of sync source %s cannot be changed, sync jobs of other mailboxes use it", "imapsync_source_token_refresh_failed": "OAuth token refresh for %s failed", "imapsync_source_scope_empty": "Select at least one domain for the chosen visibility", "imapsync_source_unavailable": "The selected sync source is unavailable", diff --git a/tests/php/Imapsync/Service/SourceServiceTest.php b/tests/php/Imapsync/Service/SourceServiceTest.php index d14d26fa7..2c13ad978 100644 --- a/tests/php/Imapsync/Service/SourceServiceTest.php +++ b/tests/php/Imapsync/Service/SourceServiceTest.php @@ -292,6 +292,20 @@ class SourceServiceTest extends TestCase $this->expectUserError('access_denied', fn() => $service->update(7, ['name' => 'b'])); } + public function testServerOnlyChangesWhenAllJobsAreAccessible(): void + { + $this->repo->method('findVisible')->willReturn(self::source(['created_by' => 'u@example.org'])); + $this->repo->expects($this->exactly(2))->method('jobMailboxes') + ->willReturnOnConsecutiveCalls(['u@example.org', 'other@example.org'], ['u@example.org']); + $this->repo->expects($this->exactly(2))->method('update'); + $service = $this->service(FakeContext::user('u@example.org')); + + $this->expectUserError(['imapsync_source_used_by_others', 'src'], fn() => $service->update(7, ['host' => 'other.example.org'])); + $service->update(7, ['host' => 'new.example.org']); + // Name or description changes keep the server and need no check + $service->update(7, ['description' => 'x']); + } + public function testUsersMayNotSwitchToXOAuth2(): void { $this->repo->method('findVisible')->willReturn(self::source(['created_by' => 'u@example.org'])); From 5eb3148bd5b1aa20963dd4308602ab19c84967c6 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:54:54 +0100 Subject: [PATCH 13/26] [Web] OIDC/LDAP: check the OAuth state, keep the mailbox on token refresh, require unique IdP users --- data/web/inc/functions.auth.inc.php | 17 +++++++++++----- data/web/inc/functions.inc.php | 12 +++++++++++ .../mailcow/Identity/Service/SyncPlanner.php | 20 ++++++++++++++++--- data/web/inc/triggers.user.inc.php | 6 ++++-- .../php/Identity/Service/SyncPlannerTest.php | 11 ++++++++++ 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/data/web/inc/functions.auth.inc.php b/data/web/inc/functions.auth.inc.php index 87566af54..cd88b2a6a 100644 --- a/data/web/inc/functions.auth.inc.php +++ b/data/web/inc/functions.auth.inc.php @@ -538,7 +538,8 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ // get the mailcow_password attribute from keycloak user $url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users"; - $queryParams = array('email' => $user, 'exact' => true); + // as string, http_build_query() would send exact=1 and Keycloak would search for substrings + $queryParams = array('email' => $user, 'exact' => 'true'); $queryString = http_build_query($queryParams); $curl = curl_init(); curl_setopt($curl, CURLOPT_TIMEOUT, 7); @@ -559,7 +560,10 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ if ($code != 200) { throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code); } - // exact=true can still return several users with that email + // exact=true can still return several users with that email, only the exact address counts + if (is_array($users)) { + $users = array_values(array_filter($users, fn($u) => is_array($u) && strcasecmp((string)($u['email'] ?? ''), $user) === 0)); + } if (!is_array($users) || count($users) != 1) { return false; } @@ -710,12 +714,15 @@ function ldap_mbox_login($user, $pass, $extra = null){ ->select(array_values(array_filter(array_merge([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'distinguishedname', 'dn'], \Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? ''))))); - $user_res = $ldap_query->firstOrFail(); - } catch (\LdapRecord\Query\ObjectNotFoundException $e) { - return false; + $entries = $ldap_query->get(); } catch (Exception $e) { throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e); } + // the login name must identify exactly one entry, several matches are ambiguous + if (!is_array($entries) || count($entries) != 1) { + return false; + } + $user_res = reset($entries); $idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp; if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) { return false; diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 79bc97b24..b4ffc013d 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -3013,6 +3013,18 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { return false; } + // A refresh keeps the logged-in mailbox: a changed IdP email or another IdP user ends the session + if (strtolower($info['email']) !== strtolower($_SESSION['mailcow_cc_username'] ?? '') || + !\Mailcow\Auth\AuthModule::fromGlobals()->idp->verifyLink($iam_settings['authsource'], $info['email'], (string)($info['sub'] ?? ''))) { + clear_session(); + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $info['email'], 'The IdP user does not match the logged-in mailbox'), + 'msg' => 'refresh_login_failed' + ); + return false; + } + // The mailbox may have been deleted or deactivated since the login $stmt = $pdo->prepare("SELECT `mailbox`.`authsource` FROM `mailbox` INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain` diff --git a/data/web/inc/lib/mailcow/Identity/Service/SyncPlanner.php b/data/web/inc/lib/mailcow/Identity/Service/SyncPlanner.php index 9870be7c4..bd6a462f2 100644 --- a/data/web/inc/lib/mailcow/Identity/Service/SyncPlanner.php +++ b/data/web/inc/lib/mailcow/Identity/Service/SyncPlanner.php @@ -44,6 +44,8 @@ class SyncPlanner /** @var array $candidates email => user to create, template still open */ $candidates = []; $duplicates = []; + /** @var array $ambiguous unlinked mailboxes whose email several IdP users have, left as they are */ + $ambiguous = []; foreach ($listing->users as $user) { if (isset($owners[$user->id])) { $username = $owners[$user->id]; @@ -55,10 +57,19 @@ class SyncPlanner $plan->skipped++; } elseif (isset($state->accounts[$user->email])) { $account = $state->accounts[$user->email]; - if ($account->externalId !== null || isset($plan->links[$user->email])) { + if ($account->externalId !== null) { self::conflict($plan, $user->email . ' is linked to another IdP user'); continue; } + if (isset($plan->links[$user->email]) || isset($ambiguous[$user->email])) { + // Which IdP user owns the mailbox is unclear: link none of them + if (!isset($ambiguous[$user->email])) { + self::conflict($plan, 'Several IdP users have the email ' . $user->email); + } + unset($plan->links[$user->email], $matched[$user->email]); + $ambiguous[$user->email] = true; + continue; + } $plan->links[$user->email] = $user->id; $matched[$user->email] = $user; } elseif (isset($state->mailboxes[$user->email]) || !$settings->importUsers || !$user->enabled @@ -85,7 +96,7 @@ class SyncPlanner if ($groups !== null) { $this->planGroups($settings, $groups, $state, $usernames, $matched, $plan); } - $this->planStates($settings, $state, $matched, $plan); + $this->planStates($settings, $state, $matched, $ambiguous, $plan); if ($settings->periodicSync) { $this->planUpdates($settings, $state, $matched, $plan); } @@ -211,10 +222,13 @@ class SyncPlanner } /** @param array $matched */ - private function planStates(Settings $settings, State $state, array $matched, SyncPlan $plan): void + private function planStates(Settings $settings, State $state, array $matched, array $ambiguous, SyncPlan $plan): void { foreach ($state->accounts as $username => $account) { $username = (string)$username; + if (isset($ambiguous[$username])) { + continue; + } $user = $matched[$username] ?? null; if ($user === null && $account->missingSince === null) { $plan->missing[] = $username; diff --git a/data/web/inc/triggers.user.inc.php b/data/web/inc/triggers.user.inc.php index 5dcde0b18..66f20fb1d 100644 --- a/data/web/inc/triggers.user.inc.php +++ b/data/web/inc/triggers.user.inc.php @@ -19,8 +19,10 @@ if ($iam_provider){ header('Location: ' . $redirect_uri); die(); } - } elseif ($_GET['code'] && $_GET['state'] === $_SESSION['oauth2state']) { - // Check given state against previously stored one to mitigate CSRF attack + } elseif (!empty($_GET['code']) && !empty($_SESSION['oauth2state']) && is_string($_GET['state'] ?? null) + && hash_equals((string)$_SESSION['oauth2state'], $_GET['state'])) { + // Check given state against previously stored one to mitigate CSRF attack, a state is used once + unset($_SESSION['oauth2state']); // Received access token in $_GET['code'] // extract info and verify user identity_provider('verify-sso'); diff --git a/tests/php/Identity/Service/SyncPlannerTest.php b/tests/php/Identity/Service/SyncPlannerTest.php index 54a5c4c10..7dc9680b6 100644 --- a/tests/php/Identity/Service/SyncPlannerTest.php +++ b/tests/php/Identity/Service/SyncPlannerTest.php @@ -26,6 +26,17 @@ class SyncPlannerTest extends TestCase $this->assertSame([], $plan->conflicts); } + public function testUnlinkedMailboxWithSeveralIdpUsersIsNeitherLinkedNorOffboarded(): void + { + $users = [self::user('u1', 'a@example.org'), self::user('u2', 'a@example.org', false), self::user('u3', 'a@example.org')]; + $plan = $this->plan($users, [new Account('a@example.org', 'A', 1)]); + + $this->assertSame([], $plan->links); + $this->assertSame(['Several IdP users have the email a@example.org'], $plan->conflicts); + $this->assertSame([], $plan->missing); + $this->assertSame([], $plan->deactivate); + } + public function testLinkedUserWithNewEmailIsAConflictAndNotCreated(): void { $plan = $this->plan([self::user('u1', 'new@example.org')], [new Account('a@example.org', 'A', 1, 'u1')]); From 4b7ebab8f32004a7a3e6329dccfb03cd6e7ff718 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:56:24 +0100 Subject: [PATCH 14/26] [Web] Mask IdP secrets for read-only API keys, deny sync job OAuth to read-only and pending sessions --- data/web/json_api.php | 9 ++++++++- data/web/syncjob-oauth.php | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/data/web/json_api.php b/data/web/json_api.php index 3a6ad2659..b6e58c47d 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -1549,7 +1549,14 @@ if (isset($_GET['query'])) { if ($object == "sync-status") { process_get_return(\Mailcow\Identity\IdentityModule::fromGlobals()->api->getStatus()); } elseif($_SESSION['mailcow_cc_role'] === 'admin') { - process_get_return($iam_settings); + $iam_settings_out = $iam_settings; + // Read-only API keys never get the secrets of the identity provider + if (($_SESSION['mailcow_cc_api_access'] ?? '') == 'ro') { + foreach (array('client_secret', 'bindpass', 'access_token') as $iam_secret) { + if (isset($iam_settings_out[$iam_secret])) $iam_settings_out[$iam_secret] = '*'; + } + } + process_get_return($iam_settings_out); } else { process_get_return(null); } diff --git a/data/web/syncjob-oauth.php b/data/web/syncjob-oauth.php index 1046dc97a..44c60370c 100644 --- a/data/web/syncjob-oauth.php +++ b/data/web/syncjob-oauth.php @@ -35,7 +35,9 @@ if (empty($_SESSION['mailcow_cc_username']) || empty($_SESSION['mailcow_cc_role' } $context = \Mailcow\Core\Context::fromSession($_SESSION); $imapsync = \Mailcow\Imapsync\ImapsyncModule::fromGlobals($context); -if (!$context->hasAcl('syncjobs')) { +// Storing a token is a write: no read-only API keys, no session with pending 2FA setup or password update +if (!$context->hasAcl('syncjobs') || $context->isReadOnlyApi() + || !empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) { syncjob_oauth_finish(false, array('error' => 'access_denied')); } From 7c73199bdefb204aaf0c49c6ed40379633b539b8 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:01:43 +0100 Subject: [PATCH 15/26] [Web] Groups: limit changes of domain admins to their domains --- .../lib/mailcow/MailboxGroup/Model/Group.php | 2 ++ .../Repository/GroupRepository.php | 2 ++ .../MailboxGroup/Service/GroupService.php | 29 ++++++++++++++-- .../MailboxGroup/Service/GroupServiceTest.php | 34 +++++++++++++++++-- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Model/Group.php b/data/web/inc/lib/mailcow/MailboxGroup/Model/Group.php index 90a5b54e7..bfb5cd9ef 100644 --- a/data/web/inc/lib/mailcow/MailboxGroup/Model/Group.php +++ b/data/web/inc/lib/mailcow/MailboxGroup/Model/Group.php @@ -42,6 +42,8 @@ class Group public array $sendAs = []; /** @var array[] {owner, rights, subscribe, state, last_error}: members may open these mailboxes (ACL group=mcg_) */ public array $imapGrants = []; + /** @var string[] owners of removed IMAP grants whose ACL Dovecot still has, not part of the grants to write */ + public array $removingImapOwners = []; /** @var array[] {owner, type, folder, access, subscribe, state, last_error}: SOGo folders shared with the members */ public array $sogoGrants = []; /** @var array[] warnings of the last write (msg values), not stored */ diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Repository/GroupRepository.php b/data/web/inc/lib/mailcow/MailboxGroup/Repository/GroupRepository.php index f414ea2cd..59b3518a1 100644 --- a/data/web/inc/lib/mailcow/MailboxGroup/Repository/GroupRepository.php +++ b/data/web/inc/lib/mailcow/MailboxGroup/Repository/GroupRepository.php @@ -501,6 +501,8 @@ class GroupRepository extends Repository $group->imapGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]), $this->query("SELECT `owner`, `rights`, `subscribe`, `state`, `last_error` FROM `mailbox_group_imap_grant` WHERE `group_id` = :id AND `state` != 'removing' ORDER BY `owner`", $id)->fetchAll(PDO::FETCH_ASSOC)); + $group->removingImapOwners = $this->query("SELECT `owner` FROM `mailbox_group_imap_grant` WHERE `group_id` = :id AND `state` = 'removing'", + $id)->fetchAll(PDO::FETCH_COLUMN); $group->sogoGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]), $this->query("SELECT `owner`, `folder_type` AS `type`, `folder`, `access`, `subscribe`, `state`, `last_error` FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id ORDER BY `owner`, `folder_type`, `folder`", $id)->fetchAll(PDO::FETCH_ASSOC)); diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Service/GroupService.php b/data/web/inc/lib/mailcow/MailboxGroup/Service/GroupService.php index 3780a1b40..9fdf0aa9f 100644 --- a/data/web/inc/lib/mailcow/MailboxGroup/Service/GroupService.php +++ b/data/web/inc/lib/mailcow/MailboxGroup/Service/GroupService.php @@ -79,6 +79,12 @@ class GroupService return false; } } + // A removed grant still opens the mailbox until Dovecot dropped its ACL + foreach ($group->removingImapOwners as $owner) { + if (!$this->context->canAccessMailbox((string)$owner)) { + return false; + } + } return true; } @@ -113,7 +119,7 @@ class GroupService $current = $this->editable($id); $group = clone $current; $this->apply($group, $input, $current); - $snapshot = $this->effects->snapshot(array_merge($current->usernames(), $group->usernames())); + $snapshot = $this->effects->snapshot($this->inScope(array_merge($current->usernames(), $group->usernames()))); $sogo = $this->repo->update($group); $group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot), $this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id)); @@ -132,7 +138,7 @@ class GroupService { $this->requireEditor(); $group = $this->editable($id); - $snapshot = $this->effects->snapshot($group->usernames()); + $snapshot = $this->effects->snapshot($this->inScope($group->usernames())); $this->sharing->removeImap($group); $sogo = $this->repo->delete($id); $group->warnings = $this->effects->apply($sogo, $snapshot); @@ -150,13 +156,30 @@ class GroupService if (!$own && !($this->canRead() && $this->context->canAccessMailbox($username))) { return []; } + $groups = $this->lookup->membershipsOf($username); + // Domain admins see the mailbox's groups in their domains, not those of other domains + if (!$own && !$this->context->hasAdminRights()) { + $groups = array_values(array_filter($groups, fn(array $group): bool => $this->context->canAccessDomain((string)$group['domain']))); + } return [ - 'groups' => $this->lookup->membershipsOf($username), + 'groups' => $groups, 'template' => $this->templates->effective([$username])[$username] ?? null, 'send_as' => $this->lookup->sendAsOf($username), ]; } + /** + * Members whose templates a change of the caller may apply: a domain admin never changes mailboxes outside + * his domains, their new effective template applies with the next admin action or IdP login. + */ + private function inScope(array $usernames): array + { + if ($this->context->hasAdminRights()) { + return $usernames; + } + return array_values(array_filter($usernames, fn(string $username): bool => $this->context->canAccessMailbox($username))); + } + /** @throws AccessDenied when the caller cannot see or edit the group */ private function editable(int $id): Group { diff --git a/tests/php/MailboxGroup/Service/GroupServiceTest.php b/tests/php/MailboxGroup/Service/GroupServiceTest.php index 731e656cc..a9816989e 100644 --- a/tests/php/MailboxGroup/Service/GroupServiceTest.php +++ b/tests/php/MailboxGroup/Service/GroupServiceTest.php @@ -301,6 +301,31 @@ class GroupServiceTest extends TestCase $this->assertTrue($this->service(self::admin())->canEdit($group)); } + public function testRemovedGrantsCountUntilDovecotDroppedThem(): void + { + $group = self::stored(); + $group->removingImapOwners = ['c@other.org']; + + $this->assertFalse($this->service(self::domainAdmin())->canEdit($group)); + $this->assertTrue($this->service(self::admin())->canEdit($group)); + } + + public function testDomainAdminChangesOnlyApplyTemplatesInHisDomains(): void + { + $this->repo->method('findVisible')->willReturn(self::stored([], ['a@example.org' => 'manual', 'c@other.org' => 'manual'])); + $this->repo->method('update')->willReturn([]); + $this->repo->method('delete')->willReturn([]); + $effects = $this->createMock(ChangeEffects::class); + $effects->method('apply')->willReturn([]); + // before and after lists of update() may repeat a member, only the set counts + $effects->expects($this->exactly(2))->method('snapshot') + ->with($this->callback(fn(array $usernames): bool => array_values(array_unique($usernames)) === ['a@example.org']))->willReturn([]); + $service = new GroupService($this->repo, $this->lookup, $this->templates, $effects, $this->sharing, self::domainAdmin()); + + $service->update(7, ['active' => 0]); + $service->delete(7); + } + public function testDeleteRemovesTheImapAclsFirst(): void { $group = self::stored(); @@ -346,13 +371,16 @@ class GroupServiceTest extends TestCase public function testMembershipsAreVisibleToTheMailboxAndItsManagers(): void { - $this->lookup->method('membershipsOf')->willReturn([['id' => 7, 'name' => 'Sales']]); + $sales = ['id' => 7, 'domain' => 'example.org', 'name' => 'Sales']; + $foreign = ['id' => 8, 'domain' => 'other.org', 'name' => 'Other']; + $this->lookup->method('membershipsOf')->willReturn([$sales, $foreign]); $this->lookup->method('sendAsOf')->willReturn(['sales@example.org']); $this->templates->method('effective')->willReturn(['a@example.org' => 'Staff']); - $expected = ['groups' => [['id' => 7, 'name' => 'Sales']], 'template' => 'Staff', 'send_as' => ['sales@example.org']]; + $expected = ['groups' => [$sales, $foreign], 'template' => 'Staff', 'send_as' => ['sales@example.org']]; $this->assertSame($expected, $this->service(FakeContext::user('a@example.org', []))->membershipsOf('A@example.org')); - $this->assertSame($expected, $this->service(self::domainAdmin())->membershipsOf('a@example.org')); + // Groups of other domains stay hidden from a domain admin + $this->assertSame(['groups' => [$sales]] + $expected, $this->service(self::domainAdmin())->membershipsOf('a@example.org')); $this->assertSame([], $this->service(FakeContext::user('b@example.org', []))->membershipsOf('a@example.org')); $this->assertSame([], $this->service(self::domainAdmin())->membershipsOf('c@other.org')); } From 06cbdc44098f749780245fbb7272d7e4c983efce Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:03:30 +0100 Subject: [PATCH 16/26] [Dockerapi] Pass "--" before positional arguments of doveadm --- data/Dockerfiles/dockerapi/modules/DockerApi.py | 14 +++++++------- docker-compose.yml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/data/Dockerfiles/dockerapi/modules/DockerApi.py b/data/Dockerfiles/dockerapi/modules/DockerApi.py index af76c5377..3de02ac3f 100644 --- a/data/Dockerfiles/dockerapi/modules/DockerApi.py +++ b/data/Dockerfiles/dockerapi/modules/DockerApi.py @@ -318,7 +318,7 @@ class DockerApi: if 'username' in request_json and 'script_name' in request_json: for container in self.sync_docker_client.containers.list(filters=filters): - cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' '" + request_json['script_name'].replace("'", "'\\''") + "'"] + cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' -- '" + request_json['script_name'].replace("'", "'\\''") + "'"] sieve_return = container.exec_run(cmd) return self.exec_run_handler('utf8_text_only', sieve_return) # api call: container_post - post_action: exec - cmd: maildir - task: cleanup @@ -491,7 +491,7 @@ class DockerApi: if mailbox in mailbox_seen: continue - acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' '{mailbox}'"]) + acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' -- '{mailbox}'"]) acls = acls.output.decode('utf-8').strip().splitlines() if len(acls) >= 2: for acl in acls[1:]: @@ -510,7 +510,7 @@ class DockerApi: if mailbox in mailbox_seen: continue - acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' '{mailbox}'"]) + acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' -- '{mailbox}'"]) acls = acls.output.decode('utf-8').strip().splitlines() if len(acls) >= 2: for acl in acls[1:]: @@ -534,7 +534,7 @@ class DockerApi: id = request_json['id'].replace("'", "'\\''") if user and mailbox and id: - acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' '{self.acl_identifier(id)}'"]) + acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' -- '{mailbox}' '{self.acl_identifier(id)}'"]) return self.exec_run_handler('generic', acl_delete_return) # api call: container_post - post_action: exec - cmd: doveadm - task: set_acl def container_post__exec__doveadm__set_acl(self, request_json, **kwargs): @@ -568,7 +568,7 @@ class DockerApi: rights += right + " " if user and mailbox and id and rights: - acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' '{self.acl_identifier(id)}' {rights}"]) + acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' -- '{mailbox}' '{self.acl_identifier(id)}' {rights}"]) return self.exec_run_handler('generic', acl_set_return) # api call: container_post - post_action: exec - cmd: doveadm - task: set_acl_all # Sets the rights of an identifier on every folder of a mailbox, no rights = delete; values are passed as @@ -585,7 +585,7 @@ class DockerApi: rights = [right.lower() for right in request_json.get('rights', []) if right.lower() in self.acl_rights] if not user or id in ('user=', 'group='): return Response(content=json.dumps({ 'type': 'danger', 'msg': 'user and id required' }, indent=4), media_type="application/json") - action = 'doveadm acl set -u "$ACL_USER" "$folder" "$ACL_ID" $ACL_RIGHTS' if rights else 'doveadm acl delete -u "$ACL_USER" "$folder" "$ACL_ID"' + action = 'doveadm acl set -u "$ACL_USER" -- "$folder" "$ACL_ID" $ACL_RIGHTS' if rights else 'doveadm acl delete -u "$ACL_USER" -- "$folder" "$ACL_ID"' script = ('set -o pipefail; doveadm mailbox list -u "$ACL_USER" | while IFS= read -r folder; do ' 'case "$folder" in Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"') acl_return = container.exec_run(["/bin/bash", "-c", script], @@ -601,7 +601,7 @@ class DockerApi: for container in self.sync_docker_client.containers.list(filters=filters): users = [str(user) for user in request_json.get('users', []) if str(user)] - flush_return = container.exec_run(["doveadm", "auth", "cache", "flush"] + users) + flush_return = container.exec_run(["doveadm", "auth", "cache", "flush", "--"] + users) return self.exec_run_handler('generic', flush_return) # Rights doveadm acl set accepts from the API diff --git a/docker-compose.yml b/docker-compose.yml index 81bdd7468..9e3d5c424 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -610,7 +610,7 @@ services: - watchdog dockerapi-mailcow: - image: ghcr.io/mailcow/dockerapi:2.14 + image: ghcr.io/mailcow/dockerapi:2.15 security_opt: - label=disable restart: always From 5c762d994936296432d916289e8908384e676a9b Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:11:56 +0100 Subject: [PATCH 17/26] [Dovecot][Netfilter] Report failed logins from mailcowauth.php, anchor default rules --- .../netfilter/modules/BanManager.py | 18 ---- .../netfilter/modules/Netfilter.py | 6 -- data/Dockerfiles/netfilter/modules/Regexes.py | 83 +++++++++++--- data/conf/dovecot/auth/mailcowauth.php | 11 +- data/conf/dovecot/auth/passwd-verify.lua | 7 +- data/conf/dovecot/dovecot.conf | 10 +- docker-compose.yml | 2 +- tests/netfilter/test_ban_manager.py | 19 ---- tests/netfilter/test_netfilter.py | 47 ++++---- tests/netfilter/test_regexes.py | 101 +++++++++++++++--- 10 files changed, 190 insertions(+), 114 deletions(-) diff --git a/data/Dockerfiles/netfilter/modules/BanManager.py b/data/Dockerfiles/netfilter/modules/BanManager.py index 77818dd4a..9a7cee9c5 100644 --- a/data/Dockerfiles/netfilter/modules/BanManager.py +++ b/data/Dockerfiles/netfilter/modules/BanManager.py @@ -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): diff --git a/data/Dockerfiles/netfilter/modules/Netfilter.py b/data/Dockerfiles/netfilter/modules/Netfilter.py index bd75938c8..ccc6c65ee 100644 --- a/data/Dockerfiles/netfilter/modules/Netfilter.py +++ b/data/Dockerfiles/netfilter/modules/Netfilter.py @@ -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 diff --git a/data/Dockerfiles/netfilter/modules/Regexes.py b/data/Dockerfiles/netfilter/modules/Regexes.py index 8818d4ee3..9cfd00bfd 100644 --- a/data/Dockerfiles/netfilter/modules/Regexes.py +++ b/data/Dockerfiles/netfilter/modules/Regexes.py @@ -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): diff --git a/data/conf/dovecot/auth/mailcowauth.php b/data/conf/dovecot/auth/mailcowauth.php index c8a73e39e..e18e8fcec 100644 --- a/data/conf/dovecot/auth/mailcowauth.php +++ b/data/conf/dovecot/auth/mailcowauth.php @@ -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 } diff --git a/data/conf/dovecot/auth/passwd-verify.lua b/data/conf/dovecot/auth/passwd-verify.lua index ddd5a316a..cb4a5ebdc 100644 --- a/data/conf/dovecot/auth/passwd-verify.lua +++ b/data/conf/dovecot/auth/passwd-verify.lua @@ -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) diff --git a/data/conf/dovecot/dovecot.conf b/data/conf/dovecot/dovecot.conf index c3789fb24..c1da4fb7c 100644 --- a/data/conf/dovecot/dovecot.conf +++ b/data/conf/dovecot/dovecot.conf @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 9e3d5c424..473b33466 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/tests/netfilter/test_ban_manager.py b/tests/netfilter/test_ban_manager.py index ebacea568..f40dcbd75 100644 --- a/tests/netfilter/test_ban_manager.py +++ b/tests/netfilter/test_ban_manager.py @@ -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() diff --git a/tests/netfilter/test_netfilter.py b/tests/netfilter/test_netfilter.py index dc702335b..8ba5eabd4 100644 --- a/tests/netfilter/test_netfilter.py +++ b/tests/netfilter/test_netfilter.py @@ -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=, ' + '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): diff --git a/tests/netfilter/test_regexes.py b/tests/netfilter/test_regexes.py index e55cac4f5..3a4bf6b77 100644 --- a/tests/netfilter/test_regexes.py +++ b/tests/netfilter/test_regexes.py @@ -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 : Protocol error; proto=ESMTP', '203.0.113.8'), - '6': ('auth: lua(user@example.org,203.0.113.9,): Password mismatch (SHA1 of given password: 0123ab)', '203.0.113.9'), - '7': ('auth: lua(nobody@example.org,203.0.113.10,): 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= to=', + '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,): Password mismatch (SHA1 of given password: 6426a0)', + 'imap-login: Disconnected: Connection closed (auth failed, 1 attempts in 2 secs): user=, ' + '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=')) + 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,): 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,): 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')) From e96fb24a671b2f1f449db3ea183d7f8dac04a662 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:44:09 +0100 Subject: [PATCH 18/26] [Nginx][Web] Pass the client address to sogo-auth for EAS/DAV logins --- data/conf/nginx/templates/nginx.conf.j2 | 6 ++++++ data/conf/nginx/templates/sites-default.conf.j2 | 1 + data/web/sogo-auth.php | 5 +++++ 3 files changed, 12 insertions(+) diff --git a/data/conf/nginx/templates/nginx.conf.j2 b/data/conf/nginx/templates/nginx.conf.j2 index 861c7e560..94db35e03 100644 --- a/data/conf/nginx/templates/nginx.conf.j2 +++ b/data/conf/nginx/templates/nginx.conf.j2 @@ -47,6 +47,12 @@ http { default ""; } + # client of an internal auth request, X-Real-IP is set by /sogo-auth-verify + map $server_port $sogo_auth_client_ip { + 65510 $http_x_real_ip; + default ""; + } + {% if HTTP_REDIRECT %} # HTTP to HTTPS redirect server { diff --git a/data/conf/nginx/templates/sites-default.conf.j2 b/data/conf/nginx/templates/sites-default.conf.j2 index 842c50a54..8a8d0950f 100644 --- a/data/conf/nginx/templates/sites-default.conf.j2 +++ b/data/conf/nginx/templates/sites-default.conf.j2 @@ -119,6 +119,7 @@ location ~ \.php$ { fastcgi_param HTTP_X_REAL_IP $remote_addr; # trusted internal-auth marker; empty for external clients (see nginx.conf map) fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal; + fastcgi_param SOGO_AUTH_CLIENT_IP $sogo_auth_client_ip; fastcgi_read_timeout 3600; fastcgi_send_timeout 3600; } diff --git a/data/web/sogo-auth.php b/data/web/sogo-auth.php index bc9694d8e..ee921c4eb 100644 --- a/data/web/sogo-auth.php +++ b/data/web/sogo-auth.php @@ -12,6 +12,11 @@ $session_var_pass = 'sogo-sso-pass'; // external clients can never supply it (bare fastcgi param, not an HTTP header) $is_internal_auth = (($_SERVER['SOGO_AUTH_INTERNAL'] ?? '') === '1'); +// the loopback is no client: use the client of the original request for logs, netfilter and API IP checks +if ($is_internal_auth && filter_var($_SERVER['SOGO_AUTH_CLIENT_IP'] ?? '', FILTER_VALIDATE_IP)) { + $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP'] = $_SERVER['SOGO_AUTH_CLIENT_IP']; +} + // validate credentials for basic auth requests if ($is_internal_auth && isset($_SERVER['PHP_AUTH_USER'])) { // load prerequisites only when required 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 19/26] [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)) From a8d1121f0e80cc548962f6e8a880b890c9fe0b2a Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:31:16 +0100 Subject: [PATCH 20/26] [Dovecot][Web] imapsync: verify certificates against the source host, make the check optional per source --- data/Dockerfiles/dovecot/imapsync_runner.py | 19 ++++++++-- data/web/api/openapi.yaml | 18 ++++++++++ data/web/inc/init_db.inc.php | 35 ++++++++++++------- .../inc/lib/mailcow/Imapsync/Model/Source.php | 3 ++ .../Imapsync/Repository/SourceRepository.php | 1 + .../Imapsync/Service/SourceService.php | 3 +- data/web/js/site/imapsync.js | 6 +++- data/web/lang/lang.de-de.json | 3 ++ data/web/lang/lang.en-gb.json | 3 ++ data/web/templates/edit/imapsync_source.twig | 9 +++++ .../web/templates/modals/imapsync_source.twig | 9 +++++ docker-compose.yml | 2 +- .../Imapsync/Service/SourceServiceTest.php | 33 +++++++++++++++++ 13 files changed, 126 insertions(+), 18 deletions(-) diff --git a/data/Dockerfiles/dovecot/imapsync_runner.py b/data/Dockerfiles/dovecot/imapsync_runner.py index 53dca5ea6..1d581ac58 100755 --- a/data/Dockerfiles/dovecot/imapsync_runner.py +++ b/data/Dockerfiles/dovecot/imapsync_runner.py @@ -36,7 +36,7 @@ token_sync_interval = 60 next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage, i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params, i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id, - s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow + s.host1, s.port1, s.enc1, s.verify_cert, s.auth_type, s.oauth_flow FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id WHERE i.active = 1 AND s.active = 1 @@ -131,6 +131,14 @@ def internal_host(host): return True return False +# SNI takes host names only +def ip_literal(host): + try: + ipaddress.ip_address(host) + return True + except ValueError: + return False + def oauth_token(j): if j['oauth_flow'] == 'authorization_code': return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {} @@ -239,8 +247,13 @@ def prepare_job(j, global_bw, files): cmd.append(opt) enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1']) if enc1: - # Check the certificate of the source (imapsync does not by default), custom sslargs1 can turn it off - cmd += [enc1, '--sslargs1', 'SSL_verify_mode=1'] + # Mail::IMAPClient starts TLS without a host name: check the certificate against host1, not its address + cmd += [enc1, '--sslargs1', 'SSL_verifycn_scheme=imap', '--sslargs1', 'SSL_verifycn_name=' + j['host1']] + if not ip_literal(j['host1']): + cmd += ['--sslargs1', 'SSL_hostname=' + j['host1']] + # imapsync does not check certificates by default; custom sslargs1 of password jobs can still change it + if j['verify_cert'] == 1: + cmd += ['--sslargs1', 'SSL_verify_mode=1'] cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])] cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')] if j['dry'] == 1: diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index 7e3578a71..b08747722 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -1936,6 +1936,14 @@ paths: - TLS - SSL - PLAIN + verify_cert: + description: >- + 1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check + it (self-signed, expired or not matching the host). + type: integer + enum: + - 0 + - 1 auth_type: description: >- authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job @@ -5080,6 +5088,15 @@ paths: - TLS - SSL - PLAIN + verify_cert: + description: >- + 1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check + it (self-signed, expired or not matching the host). Turning the check off + needs access to all sync jobs of the source, like changing the server. + type: integer + enum: + - 0 + - 1 auth_type: description: >- authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job @@ -7313,6 +7330,7 @@ paths: host1: imap.example.org port1: 993 enc1: SSL + verify_cert: 1 auth_type: XOAUTH2 oauth_flow: authorization_code oauth_token_endpoint: https://login.example.org/token diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index d0d62dbfa..ff95a0b0a 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -1098,6 +1098,7 @@ function init_db_schema() "host1" => "VARCHAR(255) NOT NULL", "port1" => "SMALLINT UNSIGNED NOT NULL", "enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'", + "verify_cert" => "TINYINT(1) NOT NULL DEFAULT '1'", "auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'", "oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'", "oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL", @@ -1679,9 +1680,15 @@ function init_db_schema() // Converted JSON may not fit into the old VARCHAR(512) $pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT"); $imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []); - $source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name"); - $source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`, `active`) - VALUES (:name, '', '', 'domain', :host, :port, :enc, :auth, 1)"); + // A legacy source is found by its connection and its only target domain (none for jobs without domain) + $source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` s WHERE `created_by` = '' AND `host1` = :host AND `port1` = :port + AND `enc1` = :enc AND `auth_type` = :auth + AND (SELECT GROUP_CONCAT(`domain`) FROM `imapsync_source_domain` d WHERE d.`source_id` = s.`id`) <=> NULLIF(:domain, '') + ORDER BY `id` LIMIT 1"); + $source_name_sel = $pdo->prepare("SELECT COUNT(*) FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name"); + // The old runner never checked certificates, legacy sources keep that + $source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `verify_cert`, `auth_type`, `active`) + VALUES (:name, 'Migrated sync jobs', '', 'domain', :host, :port, :enc, 0, :auth, 1)"); $source_domain_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)"); $job_upd = $pdo->prepare("UPDATE `imapsync` SET `source_id` = :source_id, `custom_params` = :cp, `custom_params_legacy` = :cp_legacy, `dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL"); @@ -1741,16 +1748,20 @@ function init_db_schema() $enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS'; $at = strrchr((string)$row['user2'], '@'); $domain = ($at === false) ? '' : strtolower(substr($at, 1)); - // One source per (host, port, enc, auth, domain), name always unique and <= 100 chars - $name = 'legacy-' . mb_substr($host, 0, 60) . '-' . $port . '-' . substr(sha1($host . '|' . $port . '|' . $enc . '|' . $auth . '|' . $domain), 0, 8); - $source_sel->execute(array(':name' => $name)); + // One source per (host, port, enc, auth, domain), named "host (domain)" plus " 2", " 3"... when taken (<= 100 chars) + $conn = array(':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth); + $source_sel->execute($conn + array(':domain' => $domain)); if (($source_id = $source_sel->fetchColumn()) === false) { - $source_ins->execute(array(':name' => $name, ':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth)); - $source_sel->execute(array(':name' => $name)); - if (($source_id = $source_sel->fetchColumn()) === false) continue; - } - if ($domain !== '') { - $source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain)); + $base = mb_substr($host . ($domain !== '' ? ' (' . $domain . ')' : ''), 0, 94); + for ($i = 1, $name = $base; $i < 1000; $name = $base . ' ' . ++$i) { + $source_name_sel->execute(array(':name' => $name)); + if ($source_name_sel->fetchColumn() == 0) break; + } + $source_ins->execute($conn + array(':name' => $name)); + if (($source_id = $pdo->lastInsertId()) == 0) continue; + if ($domain !== '') { + $source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain)); + } } $job_upd->execute(array( ':source_id' => $source_id, diff --git a/data/web/inc/lib/mailcow/Imapsync/Model/Source.php b/data/web/inc/lib/mailcow/Imapsync/Model/Source.php index 5ea55d054..7d237dc80 100644 --- a/data/web/inc/lib/mailcow/Imapsync/Model/Source.php +++ b/data/web/inc/lib/mailcow/Imapsync/Model/Source.php @@ -29,6 +29,7 @@ class Source public string $host = ''; public int $port = 0; public ?string $enc = 'TLS'; + public int $verifyCert = 1; public ?string $authType = 'PLAIN'; public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS; public ?string $tokenEndpoint = null; @@ -65,6 +66,7 @@ class Source $source->scope = (string)($row['scope'] ?? 'user'); $source->host = (string)($row['host1'] ?? ''); $source->port = (int)($row['port1'] ?? 0); + $source->verifyCert = (int)($row['verify_cert'] ?? 1); $source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS); $source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null; $source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null; @@ -112,6 +114,7 @@ class Source 'host1' => $this->host, 'port1' => $this->port, 'enc1' => $this->enc, + 'verify_cert' => $this->verifyCert, 'auth_type' => $this->authType, 'oauth_flow' => $this->oauthFlow, 'oauth_token_endpoint' => $this->tokenEndpoint, diff --git a/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php b/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php index 6e6b37dfe..a789f9074 100644 --- a/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php +++ b/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php @@ -206,6 +206,7 @@ class SourceRepository extends Repository 'host1' => $source->host, 'port1' => $source->port, 'enc1' => $source->enc, + 'verify_cert' => $source->verifyCert, 'auth_type' => $source->authType, 'oauth_flow' => $source->oauthFlow, 'oauth_token_endpoint' => $source->tokenEndpoint, diff --git a/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php b/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php index 2aa01b633..8438dc747 100644 --- a/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php +++ b/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php @@ -122,7 +122,7 @@ class SourceService $this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []); } $connectionChanged = self::connection($current) !== self::connection($source); - if ($connectionChanged) { + if ($connectionChanged || ($current->verifyCert && !$source->verifyCert)) { $this->requireAllJobsAccessible($source); } $this->repo->update($source, $writeTargets, $connectionChanged); @@ -296,6 +296,7 @@ class SourceService $source->host = $host; $source->port = $port; $source->enc = $enc; + $source->verifyCert = isset($input['verify_cert']) ? (int)!empty($input['verify_cert']) : $source->verifyCert; $source->authType = $authType; $source->active = isset($input['active']) ? (int)$input['active'] : $source->active; } diff --git a/data/web/js/site/imapsync.js b/data/web/js/site/imapsync.js index 70284b392..e0dfd9963 100644 --- a/data/web/js/site/imapsync.js +++ b/data/web/js/site/imapsync.js @@ -184,7 +184,11 @@ var mailcowImapsync = (function($) { dataSrc: function(json) { if (!Array.isArray(json)) return []; $.each(json, function(i, item) { - item.endpoint = wrapCell(esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')')); + item.endpoint = esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')'); + if (item.verify_cert == 0 && (item.enc || item.enc1) !== 'PLAIN') { + item.endpoint += ' '; + } + item.endpoint = wrapCell(item.endpoint); item.name_display = esc(item.name); if (item.description) item.name_display += '
' + esc(item.description) + '
'; item.name_display = wrapCell(item.name_display); diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index 6e4e5c357..da6665f2a 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -1538,6 +1538,9 @@ "source_tenant_hint": "Verzeichnis-ID (Mandant) oder eine Domain des Tenants, z. B. contoso.onmicrosoft.com. Wird in die Endpunkte eingesetzt.", "source_scope_cc_hint": "Quellen mit Client-Credentials-Flow sind immer nur für ihren Ersteller sichtbar.", "source_readonly_hint": "Diese Sync-Quelle ist freigegeben und kann nur angesehen werden.", + "source_verify_cert": "Zertifikat des Servers prüfen", + "source_verify_cert_hint": "Gilt für SSL und STARTTLS. Für Server mit selbstsigniertem, abgelaufenem oder nicht zum Host passendem Zertifikat abschalten.", + "source_verify_cert_off": "Das Zertifikat des Servers wird nicht geprüft", "copy": "Kopieren", "copied": "In die Zwischenablage kopiert", "source_view": "Anzeigen", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index cd5848d03..12a35fa7d 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -1545,6 +1545,9 @@ "source_tenant_hint": "Directory (tenant) ID or a domain of the tenant, e.g. contoso.onmicrosoft.com. It is inserted into the endpoints.", "source_scope_cc_hint": "Sources using the client credentials flow are always private to their creator.", "source_readonly_hint": "This sync source is shared with you and can only be viewed.", + "source_verify_cert": "Verify the server certificate", + "source_verify_cert_hint": "For SSL and STARTTLS. Turn off for servers with a self-signed or expired certificate or a certificate that does not match the host.", + "source_verify_cert_off": "The server certificate is not verified", "copy": "Copy", "copied": "Copied to clipboard", "source_view": "Show", diff --git a/data/web/templates/edit/imapsync_source.twig b/data/web/templates/edit/imapsync_source.twig index d6ad9d601..3805d631c 100644 --- a/data/web/templates/edit/imapsync_source.twig +++ b/data/web/templates/edit/imapsync_source.twig @@ -12,6 +12,7 @@ {% endif %}
+
@@ -99,6 +100,14 @@
+
+
+
+ +
+ {{ lang.syncjobs.source_verify_cert_hint }} +
+
diff --git a/data/web/templates/modals/imapsync_source.twig b/data/web/templates/modals/imapsync_source.twig index 43254eb8a..95bdef31b 100644 --- a/data/web/templates/modals/imapsync_source.twig +++ b/data/web/templates/modals/imapsync_source.twig @@ -9,6 +9,7 @@