From 8ead057ce9f6015327ce4eb8f51fef41a1292190 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:33:23 +0200 Subject: [PATCH 01/22] [Web] Create default mailbox template with eas and dav access --- data/web/inc/init_db.inc.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index 72018a6bc..23aa5e77f 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -1464,6 +1464,8 @@ function init_db_schema() "pop3_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['pop3_access']), "smtp_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['smtp_access']), "sieve_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['sieve_access']), + "eas_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['eas_access']), + "dav_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['dav_access']), "acl_spam_alias" => 1, "acl_tls_policy" => 1, "acl_spam_score" => 1, From 6f7fee49cd7ba844dc924285de07166e5af86bd2 Mon Sep 17 00:00:00 2001 From: Mohamed Fall Date: Mon, 13 Jul 2026 15:29:48 +0000 Subject: [PATCH 02/22] fix: cors allowed origins settings validation --- data/web/api/openapi.yaml | 4 ++-- data/web/inc/functions.inc.php | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index 77c58b609..0aca69970 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -5856,7 +5856,7 @@ paths: response: value: - type: "success" - log: ["cors", "edit", {"allowed_origins": ["*", "mail.mailcow.tld"], "allowed_methods": ["POST", "GET", "DELETE", "PUT"]}] + log: ["cors", "edit", {"allowed_origins": ["*", "https://mail.mailcow.tld"], "allowed_methods": ["POST", "GET", "DELETE", "PUT"]}] msg: "cors_headers_edited" description: OK headers: { } @@ -5873,7 +5873,7 @@ paths: schema: example: attr: - allowed_origins: ["*", "mail.mailcow.tld"] + allowed_origins: ["*", "https://mail.mailcow.tld"] allowed_methods: ["POST", "GET", "DELETE", "PUT"] properties: attr: diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 89f14b574..f9b0f6ab3 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -53,6 +53,28 @@ function valid_network($network) { function valid_hostname($hostname) { return filter_var($hostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME); } +// Validates a browser-style Origin: scheme://host[:port], no path/query/fragment/credentials +function valid_origin($origin) { + $parts = parse_url($origin); + if ($parts === false || !isset($parts['scheme']) || !isset($parts['host'])) { + return false; + } + if (!in_array(strtolower($parts['scheme']), array('http', 'https'), true)) { + return false; + } + if (isset($parts['user']) || isset($parts['pass']) || isset($parts['path']) || isset($parts['query']) || isset($parts['fragment'])) { + return false; + } + $host = $parts['host']; + $host_without_brackets = trim($host, '[]'); + if (!valid_hostname($host_without_brackets) && !filter_var($host_without_brackets, FILTER_VALIDATE_IP)) { + return false; + } + // Reject anything that doesn't round-trip to the exact same origin (e.g. stray trailing slash) + $port = isset($parts['port']) ? ':' . $parts['port'] : ''; + $rebuilt = strtolower($parts['scheme']) . '://' . strtolower($host) . $port; + return strtolower($origin) === $rebuilt; +} // Thanks to https://stackoverflow.com/a/49373789 // Validates exact ip matches and ip-in-cidr, ipv4 and ipv6 function ip_acl($ip, $networks) { @@ -2283,10 +2305,13 @@ function cors($action, $data = null) { return false; } - $allowed_origins = isset($data['allowed_origins']) ? $data['allowed_origins'] : array($_SERVER['SERVER_NAME']); + $allowed_origins = isset($data['allowed_origins']) ? $data['allowed_origins'] : array(getBaseURL()); $allowed_origins = !is_array($allowed_origins) ? array_filter(array_map('trim', explode("\n", $allowed_origins))) : $allowed_origins; - foreach ($allowed_origins as $origin) { - if (!filter_var($origin, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) && $origin != '*') { + foreach ($allowed_origins as &$origin) { + if ($origin === '*') { + continue; + } + if (!valid_origin($origin)) { $_SESSION['return'][] = array( 'type' => 'danger', 'log' => array(__FUNCTION__, $action, $data), @@ -2294,7 +2319,10 @@ function cors($action, $data = null) { ); return false; } + // browsers always send a lowercase scheme/host in the Origin header, so normalize to match + $origin = strtolower($origin); } + unset($origin); $allowed_methods = isset($data['allowed_methods']) ? $data['allowed_methods'] : array('GET', 'POST', 'PUT', 'DELETE'); $allowed_methods = !is_array($allowed_methods) ? array_map('trim', preg_split( "/( |,|;|\n)/", $allowed_methods)) : $allowed_methods; From 7dab6c63d1daf37281c624a73d236a21cdf23934 Mon Sep 17 00:00:00 2001 From: Stephen Ritz <127270018+smpaz7467@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:54:28 -0700 Subject: [PATCH 03/22] [Nginx] only bind IPv6 default_server when ENABLE_IPV6 is set The HTTP-to-HTTPS redirect server block bound `listen [::]:{{ HTTP_PORT }} default_server` unconditionally, while every other IPv6 listen directive in this template is guarded by `{% if ENABLE_IPV6 %}`. On hosts where IPv6 is disabled at the kernel level, nginx cannot bind `::` and fails to start. This only triggers when HTTP_REDIRECT is enabled and ENABLE_IPV6 is false, which is why it survives testing with ENABLE_IPV6=false alone. Guard the directive like the other six IPv6 listeners in the template. Refs #7296 Co-Authored-By: Claude Opus 4.8 (1M context) --- data/conf/nginx/templates/nginx.conf.j2 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/data/conf/nginx/templates/nginx.conf.j2 b/data/conf/nginx/templates/nginx.conf.j2 index 08a85a144..8f7e5a24f 100644 --- a/data/conf/nginx/templates/nginx.conf.j2 +++ b/data/conf/nginx/templates/nginx.conf.j2 @@ -47,7 +47,9 @@ http { server { root /web; listen {{ HTTP_PORT }} default_server; + {% if ENABLE_IPV6 %} listen [::]:{{ HTTP_PORT }} default_server; + {%endif%} server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }}; From c17cc8a7922946434f84a3220e0f5ec32419cc4a Mon Sep 17 00:00:00 2001 From: Stephen Ritz <127270018+smpaz7467@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:23:31 -0700 Subject: [PATCH 04/22] [Web] fix add/time_limited_alias silently discarding requests and validity Three defects in add/time_limited_alias: The description was read as $_data['description'] without a guard. When a client omits it, null is bound to spamalias.description, which is TEXT NOT NULL, so the insert raises a PDOException. The global exception handler is terminal, so process_add_return() never echoes anything and the caller sees HTTP 200 with an empty body while no alias was created. Default it to an empty string instead. The validity guard used a single condition whose else branch also caught the success case, so every valid validity was overwritten with the 8760 hour default and the parameter did nothing. Only invalid values were rejected. Nest the range check so a valid value survives. The OpenAPI spec documented only username and domain, while the code also reads description, validity and permanent. Spec driven clients therefore could not construct a working request. Document all three. spamalias.description is the only NOT NULL description column in the schema, which is why the same unguarded read in add/domain and add/resource does not fail, both of those columns are nullable. This does not change the generic exception handling. A database error is still swallowed into an empty HTTP 200, and the message the handler builds carries the raw PDOException, so surfacing it to API clients would need sanitising first. That is left for a separate change. Refs #7287 Co-Authored-By: Claude Opus 4.8 (1M context) --- data/web/api/openapi.yaml | 12 ++++++++++++ data/web/inc/functions.mailbox.inc.php | 19 +++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index 77c58b609..ee154c4da 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -185,6 +185,9 @@ paths: example: username: info@domain.tld domain: domain.tld + description: my time limited alias + validity: 8760 + permanent: false properties: username: description: 'the mailbox an alias should be created for' @@ -192,6 +195,15 @@ paths: domain: description: "the domain" type: string + description: + description: "a description for the alias, defaults to an empty string" + type: string + validity: + description: "how many hours the alias stays valid, 1 to 87600, defaults to 8760 (one year)" + type: integer + permanent: + description: "keep the alias after it expired, defaults to false" + type: boolean type: object summary: Create time limited alias /api/v1/add/app-passwd: diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index a9f17705d..3a002864d 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -41,13 +41,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { else { $username = $_SESSION['mailcow_cc_username']; } - if (isset($_data["validity"]) && !filter_var($_data["validity"], FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 87600)))) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'validity_missing' - ); - return false; + if (isset($_data["validity"])) { + if (!filter_var($_data["validity"], FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 87600)))) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), + 'msg' => 'validity_missing' + ); + return false; + } } else { // Default to 1 yr @@ -60,7 +62,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $permanent = 0; } $domain = $_data['domain']; - $description = $_data['description']; + // spamalias.description is NOT NULL, an absent description must not become a null insert + $description = $_data['description'] ?? ''; $valid_domains[] = mailbox('get', 'mailbox_details', $username)['domain']; $valid_alias_domains = user_get_alias_details($username)['alias_domains']; if (!empty($valid_alias_domains)) { From 14772c3a204b14d3249936d56873a82a3870d47e Mon Sep 17 00:00:00 2001 From: Stephen Ritz <127270018+smpaz7467@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:36:40 -0700 Subject: [PATCH 05/22] [Web] return sender_acl in get/mailbox API sender_acl can be set through edit/mailbox but was never returned by get/mailbox, so an API client could not read back what it had written, and get/mailbox/all / get/mailbox/{mailbox} both omitted it. Add the mailbox's internal send-as ACL (the sender_acl table rows with external = 0) to mailbox_details as sender_acl, an array of send_as values, mirroring the field edit/mailbox accepts. It is added in the same block as the other detailed fields, so the lightweight get/mailbox/reduced endpoint is unaffected. Fixes #7011 Co-Authored-By: Claude Opus 4.8 (1M context) --- data/web/inc/functions.mailbox.inc.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index a9f17705d..ba2756a4f 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -5320,6 +5320,14 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $mailboxdata['rl_scope'] = 'domain'; } $mailboxdata['is_relayed'] = $row['backupmx']; + // Internal send-as ACL for this mailbox, mirrors the sender_acl field accepted by edit/mailbox + $mailboxdata['sender_acl'] = array(); + $stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `external` = '0'"); + $stmt->execute(array(':username' => $_data)); + $sender_acl_rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + foreach ($sender_acl_rows as $sender_acl_row) { + $mailboxdata['sender_acl'][] = $sender_acl_row['send_as']; + } } $stmt = $pdo->prepare("SELECT `tag_name` FROM `tags_mailbox` WHERE `username`= :username"); From 2ebd32d2ee3392b7b3f62bd70415e2e7254fa081 Mon Sep 17 00:00:00 2001 From: SYNLINQ <69972447+SYNLINQ@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:31:57 +0200 Subject: [PATCH 06/22] Update Dockerfile bump nginx version to 1.30.4 --- data/Dockerfiles/nginx/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/Dockerfiles/nginx/Dockerfile b/data/Dockerfiles/nginx/Dockerfile index 65b34838e..d3280a33d 100644 --- a/data/Dockerfiles/nginx/Dockerfile +++ b/data/Dockerfiles/nginx/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:1.30.3-alpine +FROM nginx:1.30.4-alpine LABEL maintainer "The Infrastructure Company GmbH " ENV PIP_BREAK_SYSTEM_PACKAGES=1 From 8c9524a2fe5d765e82a65565fffde967ba726306 Mon Sep 17 00:00:00 2001 From: SYNLINQ <69972447+SYNLINQ@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:32:57 +0200 Subject: [PATCH 07/22] Update docker-compose.yml reference patched nginx image --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 30a7ba96b..dab078c88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -419,7 +419,7 @@ services: - php-fpm-mailcow - sogo-mailcow - rspamd-mailcow - image: ghcr.io/mailcow/nginx:1.30.3-1 + image: ghcr.io/mailcow/nginx:1.30.4 dns: - ${IPV4_NETWORK:-172.22.1}.254 environment: From ddd76d99cdf0ec36e5e83e0cc3939d1e1149bfd8 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:10:29 +0200 Subject: [PATCH 08/22] [Web] Add sogo_auth_internal nginx marker for sogo-auth.php --- data/conf/nginx/templates/nginx.conf.j2 | 5 +++++ data/conf/nginx/templates/sites-default.conf.j2 | 2 ++ data/web/sogo-auth.php | 8 ++++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/data/conf/nginx/templates/nginx.conf.j2 b/data/conf/nginx/templates/nginx.conf.j2 index 08a85a144..aaf4a6fb3 100644 --- a/data/conf/nginx/templates/nginx.conf.j2 +++ b/data/conf/nginx/templates/nginx.conf.j2 @@ -42,6 +42,11 @@ http { https https; } + map $server_port $sogo_auth_internal { + 65510 "1"; + 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 84bd8eae4..688f2baa6 100644 --- a/data/conf/nginx/templates/sites-default.conf.j2 +++ b/data/conf/nginx/templates/sites-default.conf.j2 @@ -116,6 +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; + # trusted internal-auth marker; empty for external clients (see nginx.conf map) + fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal; fastcgi_read_timeout 3600; fastcgi_send_timeout 3600; } diff --git a/data/web/sogo-auth.php b/data/web/sogo-auth.php index 07456a7d1..e2431bfe4 100644 --- a/data/web/sogo-auth.php +++ b/data/web/sogo-auth.php @@ -8,8 +8,12 @@ $ALLOW_ADMIN_EMAIL_LOGIN = (preg_match( $session_var_user_allowed = 'sogo-sso-user-allowed'; $session_var_pass = 'sogo-sso-pass'; +// only the internal nginx auth_request loopback (127.0.0.1:65510) sets this; +// external clients can never supply it (bare fastcgi param, not an HTTP header) +$is_internal_auth = (($_SERVER['SOGO_AUTH_INTERNAL'] ?? '') === '1'); + // validate credentials for basic auth requests -if (isset($_SERVER['PHP_AUTH_USER'])) { +if ($is_internal_auth && isset($_SERVER['PHP_AUTH_USER'])) { // load prerequisites only when required require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; @@ -80,7 +84,7 @@ elseif (isset($_GET['login'])) { exit; } // only check for admin-login on sogo GUI requests -elseif (isset($_SERVER['HTTP_X_ORIGINAL_URI']) && strcasecmp(substr($_SERVER['HTTP_X_ORIGINAL_URI'], 0, 9), "/SOGo/so/") === 0) { +elseif ($is_internal_auth && isset($_SERVER['HTTP_X_ORIGINAL_URI']) && strcasecmp(substr($_SERVER['HTTP_X_ORIGINAL_URI'], 0, 9), "/SOGo/so/") === 0) { // this is an nginx auth_request call, we check for existing sogo-sso session variables require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/vars.inc.php'; if (file_exists($_SERVER['DOCUMENT_ROOT'] . '/inc/vars.local.inc.php')) { From 8e72d22c5664f498c7a22f2639e3c622076ced75 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:17:55 +0200 Subject: [PATCH 09/22] [Web] Escape rspamd_history and rllog --- data/web/js/site/dashboard.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/data/web/js/site/dashboard.js b/data/web/js/site/dashboard.js index aee361710..198a06392 100644 --- a/data/web/js/site/dashboard.js +++ b/data/web/js/site/dashboard.js @@ -1089,6 +1089,8 @@ jQuery(function($){ return str; }).join('
\n'); item.subject = escapeHtml(item.subject); + if (item.sender_mime != null) item.sender_mime = escapeHtml(item.sender_mime); + if (item['message-id'] != null) item['message-id'] = escapeHtml(item['message-id']); var scan_time = item.time_real.toFixed(3); if (item.time_virtual) { scan_time += ' / ' + item.time_virtual.toFixed(3); @@ -1212,6 +1214,11 @@ jQuery(function($){ }); } else if (table == 'rllog') { $.each(data, function (i, item) { + if (item.header_from != null) item.header_from = escapeHtml(item.header_from); + if (item.header_subject != null) item.header_subject = escapeHtml(item.header_subject); + if (item.from != null) item.from = escapeHtml(item.from); + if (item.rcpt != null) item.rcpt = escapeHtml(item.rcpt); + if (item.message_id != null) item.message_id = escapeHtml(item.message_id); if (item.user == null) { item.user = "none"; } From 145745329e02a2275554583091d6accdb5f17ece Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:01:44 +0200 Subject: [PATCH 10/22] [Web] add dot stuffing for quarantine raw release --- data/web/inc/functions.quarantine.inc.php | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/data/web/inc/functions.quarantine.inc.php b/data/web/inc/functions.quarantine.inc.php index 5df86ef33..c89b3e408 100644 --- a/data/web/inc/functions.quarantine.inc.php +++ b/data/web/inc/functions.quarantine.inc.php @@ -22,7 +22,7 @@ function quarantine($_action, $_data = null) { return false; } $stmt = $pdo->prepare('SELECT `id` FROM `quarantine` LEFT OUTER JOIN `user_acl` ON `user_acl`.`username` = `rcpt` - WHERE `qhash` = :hash + WHERE `qhash` = :hash AND user_acl.quarantine = 1 AND rcpt IN (SELECT username FROM mailbox)'); $stmt->execute(array(':hash' => $hash)); @@ -65,7 +65,7 @@ function quarantine($_action, $_data = null) { return false; } $stmt = $pdo->prepare('SELECT `id` FROM `quarantine` LEFT OUTER JOIN `user_acl` ON `user_acl`.`username` = `rcpt` - WHERE `qhash` = :hash + WHERE `qhash` = :hash AND `user_acl`.`quarantine` = 1 AND `username` IN (SELECT `username` FROM `mailbox`)'); $stmt->execute(array(':hash' => $hash)); @@ -170,6 +170,8 @@ function quarantine($_action, $_data = null) { } elseif ($release_format == 'raw') { $detail_row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $detail_row['msg']); + // dot-stuffing per RFC5321 4.5.2: escape leading dots + $detail_row['msg'] = preg_replace('~^\.~m', '..', $detail_row['msg']); $postfix_talk = array( array('220', 'HELO quarantine' . chr(10)), array('250', 'MAIL FROM: ' . $sender . chr(10)), @@ -180,7 +182,7 @@ function quarantine($_action, $_data = null) { array('221', '') ); // Thanks to https://stackoverflow.com/questions/6632399/given-an-email-as-raw-text-how-can-i-send-it-using-php - $smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1); + $smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1); if (!$smtp_connection) { logger(array('return' => array( array( @@ -192,7 +194,7 @@ function quarantine($_action, $_data = null) { return false; } for ($i=0; $i < count($postfix_talk); $i++) { - $smtp_resource = fgets($smtp_connection, 256); + $smtp_resource = fgets($smtp_connection, 256); if (substr($smtp_resource, 0, 3) !== $postfix_talk[$i][0]) { $ret = substr($smtp_resource, 0, 3); $ret = (empty($ret)) ? '-' : $ret; @@ -465,17 +467,19 @@ function quarantine($_action, $_data = null) { } elseif ($release_format == 'raw') { $row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $row['msg']); + // dot-stuffing per RFC5321 4.5.2: escape leading dots + $row['msg'] = preg_replace('~^\.~m', '..', $row['msg']); $postfix_talk = array( array('220', 'HELO quarantine' . chr(10)), array('250', 'MAIL FROM: ' . $sender . chr(10)), array('250', 'RCPT TO: ' . $row['rcpt'] . chr(10)), array('250', 'DATA' . chr(10)), - array('354', str_replace("\n.", '', $row['msg']) . chr(10) . '.' . chr(10)), + array('354', $row['msg'] . chr(10) . '.' . chr(10)), array('250', 'QUIT' . chr(10)), array('221', '') ); // Thanks to https://stackoverflow.com/questions/6632399/given-an-email-as-raw-text-how-can-i-send-it-using-php - $smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1); + $smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1); if (!$smtp_connection) { $_SESSION['return'][] = array( 'type' => 'warning', @@ -485,7 +489,7 @@ function quarantine($_action, $_data = null) { return false; } for ($i=0; $i < count($postfix_talk); $i++) { - $smtp_resource = fgets($smtp_connection, 256); + $smtp_resource = fgets($smtp_connection, 256); if (substr($smtp_resource, 0, 3) !== $postfix_talk[$i][0]) { $ret = substr($smtp_resource, 0, 3); $ret = (empty($ret)) ? '-' : $ret; @@ -833,7 +837,7 @@ function quarantine($_action, $_data = null) { ))); return false; } - $stmt = $pdo->prepare('SELECT * FROM `quarantine` WHERE `qhash` = :hash'); + $stmt = $pdo->prepare('SELECT * FROM `quarantine` WHERE `qhash` = :hash'); $stmt->execute(array(':hash' => $hash)); return $stmt->fetch(PDO::FETCH_ASSOC); break; From 92cc8bec90e97f2559ed99bb922ea1544ddd2e30 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:29:41 +0200 Subject: [PATCH 11/22] [Web] Use parameterized LIKE for sogo_acl deletion --- data/web/inc/functions.mailbox.inc.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index a9f17705d..77dc0af6c 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -5647,8 +5647,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array( ':username' => $username )); - $stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE '%/" . $username . "/%' OR `c_uid` = :username"); + // bind LIKE pattern + escape LIKE wildcards so the value matches literally (no SQLi, no over-match) + $c_object_like = '%/' . addcslashes($username, '\\%_') . '/%'; + $stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE :c_object_like OR `c_uid` = :username"); $stmt->execute(array( + ':c_object_like' => $c_object_like, ':username' => $username )); $stmt = $pdo->prepare("DELETE FROM `sogo_store` WHERE `c_folder_id` IN (SELECT `c_folder_id` FROM `sogo_folder_info` WHERE `c_path2` = :username)"); @@ -6044,8 +6047,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array( ':username' => $username )); - $stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE '%/" . str_replace('%', '\%', $username) . "/%' OR `c_uid` = :username"); + // bind LIKE pattern + escape LIKE wildcards so the value matches literally (no SQLi, no over-match) + $c_object_like = '%/' . addcslashes($username, '\\%_') . '/%'; + $stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE :c_object_like OR `c_uid` = :username"); $stmt->execute(array( + ':c_object_like' => $c_object_like, ':username' => $username )); $stmt = $pdo->prepare("DELETE FROM `sogo_store` WHERE `c_folder_id` IN (SELECT `c_folder_id` FROM `sogo_folder_info` WHERE `c_path2` = :username)"); @@ -6201,8 +6207,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array( ':username' => $name )); - $stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE '%/" . $name . "/%' OR `c_uid` = :username"); + // bind LIKE pattern + escape LIKE wildcards so the value matches literally (no SQLi, no over-match) + $c_object_like = '%/' . addcslashes($name, '\\%_') . '/%'; + $stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE :c_object_like OR `c_uid` = :username"); $stmt->execute(array( + ':c_object_like' => $c_object_like, ':username' => $name )); $stmt = $pdo->prepare("DELETE FROM `sogo_store` WHERE `c_folder_id` IN (SELECT `c_folder_id` FROM `sogo_folder_info` WHERE `c_path2` = :username)"); From cc9af65852bcd6af5abdf052f4a5902f57dd101e Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:26:23 +0200 Subject: [PATCH 12/22] [Web] remove domain admin sso token after use --- data/web/inc/functions.domain_admin.inc.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/data/web/inc/functions.domain_admin.inc.php b/data/web/inc/functions.domain_admin.inc.php index 46b651b85..17aa4ad6a 100644 --- a/data/web/inc/functions.domain_admin.inc.php +++ b/data/web/inc/functions.domain_admin.inc.php @@ -429,14 +429,25 @@ function domain_admin_sso($_action, $_data) { switch ($_action) { case 'check': - $token = $_data; + $token = preg_replace('/[^a-zA-Z0-9-]/', '', $_data); $stmt = $pdo->prepare("SELECT `t1`.`username` FROM `da_sso` AS `t1` JOIN `admin` AS `t2` ON `t1`.`username` = `t2`.`username` WHERE `t1`.`token` = :token AND `t1`.`created` > DATE_SUB(NOW(), INTERVAL '30' SECOND) AND `t2`.`active` = 1 AND `t2`.`superadmin` = 0;"); $stmt->execute(array( - ':token' => preg_replace('/[^a-zA-Z0-9-]/', '', $token) + ':token' => $token )); $return = $stmt->fetch(PDO::FETCH_ASSOC); - return empty($return['username']) ? false : $return['username']; + if (empty($return['username'])) { + return false; + } + // single-use: consume the token; if a concurrent request already used it, deny + $del = $pdo->prepare("DELETE FROM `da_sso` WHERE `token` = :token"); + $del->execute(array( + ':token' => $token + )); + if ($del->rowCount() < 1) { + return false; + } + return $return['username']; case 'issue': if ($_SESSION['mailcow_cc_role'] != "admin") { $_SESSION['return'][] = array( From fea38c8e1bdb5f68c351c5e2a02daba8e9a1cc76 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:16:39 +0200 Subject: [PATCH 13/22] [Web] escape mailbox name --- data/web/js/site/mailbox.js | 1 + 1 file changed, 1 insertion(+) diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js index 0a0192798..5ad5cceab 100644 --- a/data/web/js/site/mailbox.js +++ b/data/web/js/site/mailbox.js @@ -996,6 +996,7 @@ jQuery(function($){ 'style="min-width:2em;width:' + item.percent_in_use + '%">' + item.percent_in_use + '%' + '' }; item.username = escapeHtml(item.username); + item.name = escapeHtml(item.name); if (Array.isArray(item.tags)){ var tags = ''; From c877fdf0a56ca4d62f0de1446acb1e26ae446c81 Mon Sep 17 00:00:00 2001 From: oidipos <32098983+oidipos@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:57:27 +0200 Subject: [PATCH 14/22] fix: remove MIME decoding of JSON encoded subject Since moving to rspamd's multipart metadata_exporter, `subject` is a JSON encoded UTF-8 string and must not be MIME decoded. --- data/conf/rspamd/meta_exporter/pipe.php | 2 +- data/conf/rspamd/meta_exporter/pushover.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/conf/rspamd/meta_exporter/pipe.php b/data/conf/rspamd/meta_exporter/pipe.php index 0a9ed59e2..28b06d3d6 100644 --- a/data/conf/rspamd/meta_exporter/pipe.php +++ b/data/conf/rspamd/meta_exporter/pipe.php @@ -53,7 +53,7 @@ $raw_data = mb_convert_encoding($raw_data_content, 'HTML-ENTITIES', "UTF-8"); $raw_size = (int)$_FILES['message']['size']; $qid = $meta['qid'] ?? 'unknown'; -$subject = iconv_mime_decode($meta['subject'] ?? ''); +$subject = $meta['subject'] ?? ''; $score = $meta['score'] ?? 0; $rcpts = $meta['rcpt'] ?? array(); $user = $meta['user'] ?? 'unknown'; diff --git a/data/conf/rspamd/meta_exporter/pushover.php b/data/conf/rspamd/meta_exporter/pushover.php index a2cd0995b..188d0a57b 100644 --- a/data/conf/rspamd/meta_exporter/pushover.php +++ b/data/conf/rspamd/meta_exporter/pushover.php @@ -50,7 +50,7 @@ $qid = $meta['qid'] ?? 'unknown'; $rcpts = $meta['rcpt'] ?? array(); $sender = $meta['from'] ?? ''; $ip = $meta['ip'] ?? 'unknown'; -$subject = iconv_mime_decode($meta['subject'] ?? ''); +$subject = $meta['subject'] ?? ''; $messageid= $meta['message_id'] ?? ''; $priority = 0; From e245ac04d9a074291d91deb4584e3a2e7c7f0f35 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:21:29 +0200 Subject: [PATCH 15/22] [Web] enforce tenant boundary for SOGo SSO --- data/web/sogo-auth.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/data/web/sogo-auth.php b/data/web/sogo-auth.php index e2431bfe4..bc9694d8e 100644 --- a/data/web/sogo-auth.php +++ b/data/web/sogo-auth.php @@ -54,6 +54,11 @@ elseif (isset($_GET['login'])) { (($_SESSION['acl']['login_as'] == "1" && $ALLOW_ADMIN_EMAIL_LOGIN !== 0) || ($is_dual === false && $login == $_SESSION['mailcow_cc_username']))) { if (filter_var($login, FILTER_VALIDATE_EMAIL)) { if (user_get_alias_details($login) !== false) { + // enforce tenant boundary + if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $login)) { + header("Location: /"); + exit; + } // Block SOGo access if pending actions (2FA setup, password update) if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) { header("Location: /"); From b4bb1a625b787691d5ffd8ad9ae978e0163aff87 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:07:20 +0200 Subject: [PATCH 16/22] Update actions/stale action to v11 (#7375) --- .github/workflows/close_old_issues_and_prs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/close_old_issues_and_prs.yml b/.github/workflows/close_old_issues_and_prs.yml index bba04b0d1..13dbac279 100644 --- a/.github/workflows/close_old_issues_and_prs.yml +++ b/.github/workflows/close_old_issues_and_prs.yml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - name: Mark/Close Stale Issues and Pull Requests 🗑️ - uses: actions/stale@v10.4.0 + uses: actions/stale@v11.0.0 with: repo-token: ${{ secrets.STALE_ACTION_PAT }} days-before-stale: 60 From 54170d075a4cccd624809ff2e52356e3fb09238c Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:32:18 +0200 Subject: [PATCH 17/22] [Web] Move mailcow update check to server side --- data/web/inc/ajax/update_check.php | 67 ++++++++++++++++++++++++++++++ data/web/js/site/dashboard.js | 51 ++++++++--------------- 2 files changed, 85 insertions(+), 33 deletions(-) create mode 100644 data/web/inc/ajax/update_check.php diff --git a/data/web/inc/ajax/update_check.php b/data/web/inc/ajax/update_check.php new file mode 100644 index 000000000..6bf016001 --- /dev/null +++ b/data/web/inc/ajax/update_check.php @@ -0,0 +1,67 @@ + 'error', 'message' => 'access denied')); + exit; +} + +$owner = $GLOBALS['MAILCOW_GIT_OWNER']; +$repo = $GLOBALS['MAILCOW_GIT_REPO']; +$current = $GLOBALS['MAILCOW_GIT_VERSION']; + +// Cache key is bound to the running version, so an update invalidates it naturally +$cache_key = 'MAILCOW_UPDATE_CHECK/' . $current; + +// Serve cached result if present (one GitHub call per TTL, not one per page load) +$cached = $redis->get($cache_key); +if ($cached !== false) { + echo $cached; + exit; +} + +// GitHub requires a User-Agent and rate-limits unauthenticated requests to 60/h per IP +function github_get($url) { + $ch = curl_init($url); + curl_setopt_array($ch, array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_USERAGENT => 'mailcow-update-check', + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => array('Accept: application/vnd.github+json'), + )); + $body = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($body === false || $code !== 200) return false; + $json = json_decode($body, true); + return is_array($json) ? $json : false; +} + +$latest = github_get("https://api.github.com/repos/{$owner}/{$repo}/releases/latest"); +$release = github_get("https://api.github.com/repos/{$owner}/{$repo}/releases/tags/{$current}"); + +// Any failure (rate limit, offline, unexpected payload) -> honest error, cached briefly +if ($latest === false || $release === false || + empty($latest['tag_name']) || empty($latest['created_at']) || empty($release['created_at'])) { + $result = json_encode(array('status' => 'error', 'message' => 'could not reach GitHub API')); + $redis->setex($cache_key, 300, $result); + echo $result; + exit; +} + +$date_latest = strtotime($latest['created_at']); +$date_current = strtotime($release['created_at']); + +if ($date_latest <= $date_current) { + $result = json_encode(array('status' => 'no_update')); +} else { + $result = json_encode(array('status' => 'update_available', 'latest_tag' => $latest['tag_name'])); +} + +// Cache the positive result for an hour +$redis->setex($cache_key, 3600, $result); +echo $result; diff --git a/data/web/js/site/dashboard.js b/data/web/js/site/dashboard.js index aee361710..eb50d698b 100644 --- a/data/web/js/site/dashboard.js +++ b/data/web/js/site/dashboard.js @@ -30,7 +30,7 @@ $(document).ready(function() { // create host cpu and mem charts createHostCpuAndMemChart(); // check for new version - if (mailcow_info.branch === "master"){ + if (mailcow_info.branch === "master" && mailcow_cc_role === "admin"){ check_update(mailcow_info.version_tag, mailcow_info.project_url); } $("#mailcow_version").click(function(){ @@ -1669,41 +1669,26 @@ function createHostCpuAndMemChart(){ function check_update(current_version, github_repo_url){ if (!current_version || !github_repo_url) return false; - var github_account = github_repo_url.split("/")[3]; - var github_repo_name = github_repo_url.split("/")[4]; - // get details about latest release - window.fetch("https://api.github.com/repos/"+github_account+"/"+github_repo_name+"/releases/latest", {method:'GET',cache:'no-cache'}).then(function(response) { + window.fetch("/inc/ajax/update_check.php", {method:'GET',cache:'no-cache'}).then(function(response) { + if (!response.ok) throw new Error("update check returned " + response.status); return response.json(); - }).then(function(latest_data) { - // get details about current release - window.fetch("https://api.github.com/repos/"+github_account+"/"+github_repo_name+"/releases/tags/"+current_version, {method:'GET',cache:'no-cache'}).then(function(response) { - return response.json(); - }).then(function(current_data) { - // compare releases - var date_current = new Date(current_data.created_at); - var date_latest = new Date(latest_data.created_at); - if (date_latest.getTime() <= date_current.getTime()){ - // no update available - $("#mailcow_update").removeClass("text-warning text-danger").addClass("text-success"); - $("#mailcow_update").html("" + lang_debug.no_update_available + ""); - } else { - // update available - $("#mailcow_update").removeClass("text-danger text-success").addClass("text-warning"); - $("#mailcow_update").html(lang_debug.update_available + ` `+latest_data.tag_name+``); - $("#mailcow_update_changelog").click(function(){ - if (mailcow_cc_role !== "admin" && mailcow_cc_role !== "domainadmin") - return; + }).then(function(data) { + if (data.status === "no_update") { + $("#mailcow_update").removeClass("text-warning text-danger").addClass("text-success"); + $("#mailcow_update").html("" + lang_debug.no_update_available + ""); + } else if (data.status === "update_available") { + $("#mailcow_update").removeClass("text-danger text-success").addClass("text-warning"); + $("#mailcow_update").html(lang_debug.update_available + ` `+data.latest_tag+``); + $("#mailcow_update_changelog").click(function(){ + if (mailcow_cc_role !== "admin" && mailcow_cc_role !== "domainadmin") + return; - showVersionModal("New Release " + latest_data.tag_name, latest_data.tag_name); - }) - } - }).catch(err => { - // err - console.log(err); - $("#mailcow_update").removeClass("text-success text-warning").addClass("text-danger"); - $("#mailcow_update").html(""+ lang_debug.update_failed +""); - }); + showVersionModal("New Release " + data.latest_tag, data.latest_tag); + }) + } else { + throw new Error(data.message || "update check failed"); + } }).catch(err => { // err console.log(err); From f44bd2f36a802d2a2d948c579fb34948e8181038 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:19:45 +0200 Subject: [PATCH 18/22] [Web] document sender_acl in get/mailbox API examples --- data/web/api/openapi.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index ee154c4da..093f9dd51 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -4920,6 +4920,7 @@ paths: quota: 3221225472 quota_used: 0 rl: false + sender_acl: ["otherbox@doman3.tld", "@aliasdomain.tld"] spam_aliases: 0 username: info@doman3.tld tags: ["tag1", "tag2"] @@ -5846,6 +5847,7 @@ paths: quota: 3221225472 quota_used: 0 rl: false + sender_acl: ["otherbox@domain3.tld", "@aliasdomain.tld"] spam_aliases: 0 username: info@domain3.tld tags: ["tag1", "tag2"] From 36c70db86cea54dcf810343c8d6b2cc10435ffe9 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:37:18 +0200 Subject: [PATCH 19/22] [Web] harden CORS origin matching and add Vary: Origin --- data/web/inc/functions.inc.php | 38 ++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index f9b0f6ab3..dfd860a9b 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -75,6 +75,17 @@ function valid_origin($origin) { $rebuilt = strtolower($parts['scheme']) . '://' . strtolower($host) . $port; return strtolower($origin) === $rebuilt; } +// Bring an origin into the exact form a browser sends it (scheme://host[:port], lowercase). +function normalize_cors_origin($origin) { + $origin = trim($origin); + if ($origin === '*') { + return '*'; + } + if ($origin !== '' && !preg_match('~^[a-z][a-z0-9+.-]*://~i', $origin)) { + $origin = 'https://' . $origin; + } + return valid_origin($origin) ? strtolower($origin) : false; +} // Thanks to https://stackoverflow.com/a/49373789 // Validates exact ip matches and ip-in-cidr, ipv4 and ipv6 function ip_acl($ip, $networks) { @@ -2370,26 +2381,35 @@ function cors($action, $data = null) { ); } - $cors_settings = !$cors_settings ? array('allowed_origins' => $_SERVER['SERVER_NAME'], 'allowed_methods' => 'GET, POST, PUT, DELETE') : $cors_settings; - $cors_settings['allowed_origins'] = empty($cors_settings['allowed_origins']) ? $_SERVER['SERVER_NAME'] : $cors_settings['allowed_origins']; + $cors_settings = !$cors_settings ? array('allowed_origins' => getBaseURL(), 'allowed_methods' => 'GET, POST, PUT, DELETE') : $cors_settings; + $cors_settings['allowed_origins'] = empty($cors_settings['allowed_origins']) ? getBaseURL() : $cors_settings['allowed_origins']; $cors_settings['allowed_methods'] = empty($cors_settings['allowed_methods']) ? 'GET, POST, PUT, DELETE, OPTION' : $cors_settings['allowed_methods']; return $cors_settings; break; case "set_headers": $cors_settings = cors('get'); + // normalize the stored list; it may still hold bare hostnames written before origins were validated as origins + $allowed_origins = array_filter(array_map('normalize_cors_origin', explode(',', $cors_settings['allowed_origins']))); + $origin = isset($_SERVER['HTTP_ORIGIN']) ? normalize_cors_origin($_SERVER['HTTP_ORIGIN']) : false; // check if requested origin is in allowed origins - $allowed_origins = explode(', ', $cors_settings['allowed_origins']); - $cors_settings['allowed_origins'] = $allowed_origins[0]; - if (in_array('*', $allowed_origins)){ - $cors_settings['allowed_origins'] = '*'; - } else if (array_key_exists('HTTP_ORIGIN', $_SERVER) && in_array($_SERVER['HTTP_ORIGIN'], $allowed_origins)) { - $cors_settings['allowed_origins'] = $_SERVER['HTTP_ORIGIN']; + $allow_origin = null; + if (in_array('*', $allowed_origins, true)) { + $allow_origin = '*'; + } else if ($origin !== false && in_array($origin, $allowed_origins, true)) { + $allow_origin = $origin; } // always allow OPTIONS for preflight request $cors_settings["allowed_methods"] = empty($cors_settings["allowed_methods"]) ? 'OPTIONS' : $cors_settings["allowed_methods"] . ', ' . 'OPTIONS'; - header('Access-Control-Allow-Origin: ' . $cors_settings['allowed_origins']); + // a disallowed origin gets no Access-Control-Allow-Origin at all; echoing a different origin than the requesting one tells a browser nothing + if ($allow_origin !== null) { + header('Access-Control-Allow-Origin: ' . $allow_origin); + } + if ($allow_origin !== '*') { + // the response depends on the request origin, keep caches from mixing them up + header('Vary: Origin', false); + } header('Access-Control-Allow-Methods: '. $cors_settings['allowed_methods']); header('Access-Control-Allow-Headers: Accept, Content-Type, X-Api-Key, Origin'); From 7036dbf4f855ff238c9a8a57e957f7ee4178ca81 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:50:18 +0200 Subject: [PATCH 20/22] [Rspamd] update to 4.1.4 --- data/Dockerfiles/rspamd/Dockerfile | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/Dockerfiles/rspamd/Dockerfile b/data/Dockerfiles/rspamd/Dockerfile index d37d1226a..13e56357b 100644 --- a/data/Dockerfiles/rspamd/Dockerfile +++ b/data/Dockerfiles/rspamd/Dockerfile @@ -2,7 +2,7 @@ FROM debian:trixie-slim LABEL maintainer="The Infrastructure Company GmbH " ARG DEBIAN_FRONTEND=noninteractive -ARG RSPAMD_VER=rspamd_4.1.0-1~e2b0b18 +ARG RSPAMD_VER=rspamd_4.1.4-1~beb659b ARG CODENAME=trixie ENV LC_ALL=C diff --git a/docker-compose.yml b/docker-compose.yml index dab078c88..8b24b23c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,7 @@ services: - clamd rspamd-mailcow: - image: ghcr.io/mailcow/rspamd:4.1.0-1 + image: ghcr.io/mailcow/rspamd:4.1.4-1 stop_grace_period: 30s depends_on: - dovecot-mailcow From bd037d5644fa133e60909ef17d743019f67a244d Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:51:21 +0200 Subject: [PATCH 21/22] [Nginx] Set image tag to 1.30.4-1 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index dab078c88..a2bcae9db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -419,7 +419,7 @@ services: - php-fpm-mailcow - sogo-mailcow - rspamd-mailcow - image: ghcr.io/mailcow/nginx:1.30.4 + image: ghcr.io/mailcow/nginx:1.30.4-1 dns: - ${IPV4_NETWORK:-172.22.1}.254 environment: From d64c923aca1d80f8e2b73b0664500399a60542e2 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:33:55 +0200 Subject: [PATCH 22/22] [ACME] Skip mta-sts certificate request when MTA-STS is not active for a domain --- data/Dockerfiles/acme/acme.sh | 21 +++++++++++++++++++++ data/web/lang/lang.de-de.json | 1 + data/web/lang/lang.en-gb.json | 1 + data/web/templates/edit/domain.twig | 2 +- docker-compose.yml | 2 +- 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/data/Dockerfiles/acme/acme.sh b/data/Dockerfiles/acme/acme.sh index 271de4fc9..6b7b74300 100755 --- a/data/Dockerfiles/acme/acme.sh +++ b/data/Dockerfiles/acme/acme.sh @@ -249,12 +249,33 @@ while true; do done <<< "${SQL_DOMAINS}" if [[ ${ONLY_MAILCOW_HOSTNAME} != "y" ]]; then + # Fetch all domains with an active MTA-STS policy once. + unset MTA_STS_ACTIVE_DOMAINS + declare -A MTA_STS_ACTIVE_DOMAINS + if [[ ${AUTODISCOVER_SAN} == "y" ]]; then + SQL_MTA_STS_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM mta_sts WHERE active = 1" -Bs) + if [[ $? -eq 0 ]]; then + while read mta_sts_domain; do + if [[ -z "${mta_sts_domain}" ]]; then + # ignore empty lines + continue + fi + MTA_STS_ACTIVE_DOMAINS["${mta_sts_domain}"]=1 + done <<< "${SQL_MTA_STS_DOMAINS}" + fi + fi for SQL_DOMAIN in "${SQL_DOMAIN_ARR[@]}"; do unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}" + # Skip mta-sts subdomain unless MTA-STS is enabled (active) for this domain + if [[ "${SUBDOMAIN}" == "mta-sts" && -z "${MTA_STS_ACTIVE_DOMAINS[${SQL_DOMAIN}]}" ]]; then + log_f "MTA-STS is not enabled for ${SQL_DOMAIN} - skipping mta-sts subdomain certificate" + continue + fi + # Skip if subdomain matches MAILCOW_HOSTNAME if [[ "${FULL_SUBDOMAIN}" == "${MAILCOW_HOSTNAME}" ]]; then continue diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index c19eb209b..28ad0d72b 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -728,6 +728,7 @@ "mta_sts_mx": "MX-Server", "mta_sts_mx_info": "Erlaubt das Senden nur an explizit aufgeführte Mailserver-Hostnamen; der sendende MTA überprüft, ob der DNS-MX-Hostname mit der Richtlinienliste übereinstimmt, und erlaubt die Zustellung nur mit einem gültigen TLS-Zertifikat (schützt vor MITM).", "mta_sts_mx_notice": "Es können mehrere MX-Server angegeben werden (durch Kommas getrennt).", + "mta_sts_active_info": "Wenn nicht angehakt, wird die MTA-STS-Richtlinie nicht veröffentlicht und für die mta-sts-Subdomain kein Zertifikat über ACME angefordert.", "multiple_bookings": "Mehrfaches Buchen", "nexthop": "Next Hop", "none_inherit": "Keine Auswahl / Erben", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 873d7b98d..b8a11e5a5 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -728,6 +728,7 @@ "mta_sts_mx": "MX server", "mta_sts_mx_info": "Allows sending only to explicitly listed mail server hostnames; the sending MTA checks if the DNS MX hostname matches the policy list, and only allows delivery with a valid TLS certificate (guards against MITM).", "mta_sts_mx_notice": "Multiple MX servers can be specified (separated by commas).", + "mta_sts_active_info": "If unchecked, the MTA-STS policy will not be published and no certificate will be requested for the mta-sts subdomain via ACME.", "multiple_bookings": "Multiple bookings", "none_inherit": "None / Inherit", "nexthop": "Next hop", diff --git a/data/web/templates/edit/domain.twig b/data/web/templates/edit/domain.twig index 83353079b..45ec654ce 100644 --- a/data/web/templates/edit/domain.twig +++ b/data/web/templates/edit/domain.twig @@ -340,7 +340,7 @@
- +
diff --git a/docker-compose.yml b/docker-compose.yml index 76359151b..4742fef17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -465,7 +465,7 @@ services: condition: service_started unbound-mailcow: condition: service_healthy - image: ghcr.io/mailcow/acme:1.97 + image: ghcr.io/mailcow/acme:1.98 dns: - ${IPV4_NETWORK:-172.22.1}.254 environment: