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 1/7] [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 2/7] [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 3/7] [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 4/7] [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 5/7] [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 6/7] [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 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 7/7] [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: /");