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 1/9] [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 2/9] [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 3/9] [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 4/9] [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 5/9] [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 6/9] [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 7/9] [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 8/9] [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 9/9] [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"])) {