Merge branch 'fix/sec' into fix/rework-bugs

This commit is contained in:
FreddleSpl0it committed 2026-11-04 11:46:47 +01:00
commit 3c03f8e567
22 files changed
+186 -78

No files matched your search

+6
View File
@@ -60,6 +60,7 @@ adapt_new_options() {
"ACME_DNS_CHALLENGE" "ACME_DNS_CHALLENGE"
"ACME_DNS_PROVIDER" "ACME_DNS_PROVIDER"
"ACME_ACCOUNT_EMAIL" "ACME_ACCOUNT_EMAIL"
"NGINX_MAX_BODY_SIZE"
) )
sed -i --follow-symlinks '$a\' mailcow.conf 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 '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf
echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> 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 echo "${option}=" >> mailcow.conf
;; ;;
+7
View File
@@ -1,4 +1,5 @@
import os import os
import re
import subprocess import subprocess
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
@@ -44,6 +45,11 @@ def prepare_template_vars():
ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1") ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1")
additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "") additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "")
trusted_proxies = os.getenv("TRUSTED_PROXIES", "") 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 = { template_vars = {
'IPV4_NETWORK': ipv4_network, 'IPV4_NETWORK': ipv4_network,
@@ -60,6 +66,7 @@ def prepare_template_vars():
'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"), 'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"),
'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false", 'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false",
'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"), 'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"),
'NGINX_MAX_BODY_SIZE': max_body_size,
} }
ssl_dir = '/etc/ssl/mail/' ssl_dir = '/etc/ssl/mail/'
+5 -4
View File
@@ -47,10 +47,11 @@ http {
default ""; default "";
} }
# client of an internal auth request, X-Real-IP is set by /sogo-auth-verify # Client address for PHP: on the internal sogo-auth listener nginx itself is the client,
map $server_port $sogo_auth_client_ip { # the real address comes from the X-Real-IP header set by /sogo-auth-verify
65510 $http_x_real_ip; map $sogo_auth_internal $mailcow_client_ip {
default ""; "1" $http_x_real_ip;
default $remote_addr;
} }
{% if HTTP_REDIRECT %} {% if HTTP_REDIRECT %}
@@ -22,7 +22,8 @@ add_header Referrer-Policy strict-origin;
index index.php index.html; 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 on;
gzip_disable "msie6"; gzip_disable "msie6";
@@ -116,10 +117,10 @@ location ~ \.php$ {
include /etc/nginx/fastcgi_params; include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info; 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) # trusted internal-auth marker; empty for external clients (see nginx.conf map)
fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal; fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
fastcgi_param SOGO_AUTH_CLIENT_IP $sogo_auth_client_ip;
fastcgi_read_timeout 3600; fastcgi_read_timeout 3600;
fastcgi_send_timeout 3600; fastcgi_send_timeout 3600;
} }
@@ -182,6 +183,7 @@ location /sogo-auth-verify {
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
proxy_pass http://127.0.0.1:65510/sogo-auth; proxy_pass http://127.0.0.1:65510/sogo-auth;
proxy_pass_request_body off; proxy_pass_request_body off;
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
} }
location ^~ /Microsoft-Server-ActiveSync { location ^~ /Microsoft-Server-ActiveSync {
@@ -205,7 +207,7 @@ location ^~ /Microsoft-Server-ActiveSync {
proxy_busy_buffers_size 512k; proxy_busy_buffers_size 512k;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
client_body_buffer_size 512k; client_body_buffer_size 512k;
client_max_body_size 0; client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
} }
location ^~ /SOGo { location ^~ /SOGo {
@@ -256,7 +258,7 @@ location ^~ /SOGo {
proxy_send_timeout 3600; proxy_send_timeout 3600;
proxy_read_timeout 3600; proxy_read_timeout 3600;
client_body_buffer_size 128k; client_body_buffer_size 128k;
client_max_body_size 0; client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
break; break;
} }
+12 -3
View File
@@ -765,13 +765,22 @@ rspamd_config:register_symbol({
end end
}) })
-- Expand a domain footer: replace only "{= key =}" placeholders with their values.
-- 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({ rspamd_config:register_symbol({
name = 'MOO_FOOTER', name = 'MOO_FOOTER',
type = 'prefilter', type = 'prefilter',
callback = function(task) callback = function(task)
local cjson = require "cjson" local cjson = require "cjson"
local lua_mime = require "lua_mime" local lua_mime = require "lua_mime"
local lua_util = require "lua_util"
local rspamd_logger = require "rspamd_logger" local rspamd_logger = require "rspamd_logger"
local rspamd_http = require "rspamd_http" local rspamd_http = require "rspamd_http"
local envfrom = task:get_from(1) local envfrom = task:get_from(1)
@@ -844,10 +853,10 @@ rspamd_config:register_symbol({
end end
end end
if footer.html and footer.html ~= "" then 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 end
if footer.plain and footer.plain ~= "" then 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 end
-- add footer -- add footer
+5 -6
View File
@@ -216,8 +216,9 @@ foreach ($rcpts as $rcpt) {
foreach ($rcpt_final_mailboxes as $rcpt_final) { foreach ($rcpt_final_mailboxes as $rcpt_final) {
error_log("QUARANTINE: quarantine pipe: processing quarantine message for rcpt " . $rcpt_final . PHP_EOL); error_log("QUARANTINE: quarantine pipe: processing quarantine message for rcpt " . $rcpt_final . PHP_EOL);
try { try {
$stmt = $pdo->prepare("INSERT INTO `quarantine` (`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
VALUES (:qid, :subject, :score, :sender, :rcpt, :symbols, :user, :ip, :msg, :action, :fuzzy_hashes)"); $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( $stmt->execute(array(
':qid' => $qid, ':qid' => $qid,
':subject' => $subject, ':subject' => $subject,
@@ -229,11 +230,9 @@ foreach ($rcpt_final_mailboxes as $rcpt_final) {
':ip' => $ip, ':ip' => $ip,
':msg' => $raw_data, ':msg' => $raw_data,
':action' => $action, ':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 ( $stmt = $pdo->prepare('DELETE FROM `quarantine` WHERE `rcpt` = :rcpt AND `id` NOT IN (
SELECT `id` SELECT `id`
FROM ( FROM (
+47 -48
View File
@@ -144,6 +144,38 @@ try {
} }
$username = trim((string)$email); $username = trim((string)$email);
if (!filter_var($username, FILTER_VALIDATE_EMAIL)) {
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => "none",
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: invalid email address in request"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
$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) {
// Silently fail
}
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>600</ErrorCode>
<Message>Invalid Request</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
try { try {
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox` $stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain` INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
@@ -169,46 +201,8 @@ catch(PDOException $e) {
exit(0); exit(0);
} }
// Mailbox not found or not active - return generic error to prevent user enumeration $email = $username;
if (empty($MailboxData)) { $displayname = $email;
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email,
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: mailbox not found or inactive"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
$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) {
// Silently fail
}
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>600</ErrorCode>
<Message>Invalid Request</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
if (!empty($MailboxData['name'])) {
$displayname = $MailboxData['name'];
}
else {
$displayname = $email;
}
try { try {
$json = json_encode( $json = json_encode(
array( array(
@@ -216,11 +210,16 @@ try {
"ua" => $_SERVER['HTTP_USER_AGENT'], "ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email, "user" => $email,
"ip" => $_SERVER['REMOTE_ADDR'], "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->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100); $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) { catch (RedisException $e) {
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
@@ -243,7 +242,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Server><?=$autodiscover_config['imap']['server'];?></Server> <Server><?=$autodiscover_config['imap']['server'];?></Server>
<Port><?=$autodiscover_config['imap']['port'];?></Port> <Port><?=$autodiscover_config['imap']['port'];?></Port>
<DomainRequired>off</DomainRequired> <DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName> <LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
<SPA>off</SPA> <SPA>off</SPA>
<SSL>on</SSL> <SSL>on</SSL>
<AuthRequired>on</AuthRequired> <AuthRequired>on</AuthRequired>
@@ -253,7 +252,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Server><?=$autodiscover_config['smtp']['server'];?></Server> <Server><?=$autodiscover_config['smtp']['server'];?></Server>
<Port><?=$autodiscover_config['smtp']['port'];?></Port> <Port><?=$autodiscover_config['smtp']['port'];?></Port>
<DomainRequired>off</DomainRequired> <DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName> <LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
<SPA>off</SPA> <SPA>off</SPA>
<SSL>on</SSL> <SSL>on</SSL>
<AuthRequired>on</AuthRequired> <AuthRequired>on</AuthRequired>
@@ -265,15 +264,15 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
?> ?>
<Protocol> <Protocol>
<Type>CalDAV</Type> <Type>CalDAV</Type>
<Server>https://<?=$autodiscover_config['caldav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['caldav']['port']; ?>/SOGo/dav/<?=$email;?>/</Server> <Server>https://<?=$autodiscover_config['caldav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['caldav']['port']; ?>/SOGo/dav/<?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?>/</Server>
<DomainRequired>off</DomainRequired> <DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName> <LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
</Protocol> </Protocol>
<Protocol> <Protocol>
<Type>CardDAV</Type> <Type>CardDAV</Type>
<Server>https://<?=$autodiscover_config['carddav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['carddav']['port']; ?>/SOGo/dav/<?=$email;?>/</Server> <Server>https://<?=$autodiscover_config['carddav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['carddav']['port']; ?>/SOGo/dav/<?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?>/</Server>
<DomainRequired>off</DomainRequired> <DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName> <LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
</Protocol> </Protocol>
<?php <?php
} }
@@ -288,7 +287,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Culture>en:en</Culture> <Culture>en:en</Culture>
<User> <User>
<DisplayName><?=htmlspecialchars($displayname, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></DisplayName> <DisplayName><?=htmlspecialchars($displayname, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></DisplayName>
<EMailAddress><?=$email;?></EMailAddress> <EMailAddress><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></EMailAddress>
</User> </User>
<Action> <Action>
<Settings> <Settings>
+2 -1
View File
@@ -227,7 +227,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
if (isset($_GET['syncjob']) && if (isset($_GET['syncjob']) &&
is_numeric($_GET['syncjob'])) { is_numeric($_GET['syncjob'])) {
$id = $_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'; $template = 'edit/syncjob.twig';
} }
elseif (isset($_GET['syncjob_source']) && elseif (isset($_GET['syncjob_source']) &&
+9
View File
@@ -74,6 +74,15 @@ function admin($_action, $_data = null) {
$usernames = $_data['username']; $usernames = $_data['username'];
} }
foreach ($usernames as $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); $is_now = admin('details', $username);
if (!empty($is_now)) { if (!empty($is_now)) {
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active']; $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
@@ -5,6 +5,15 @@ function app_passwd($_action, $_data = null) {
$_data_log = $_data; $_data_log = $_data;
!isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*'; !isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*';
!isset($_data_log['app_passwd2']) ?: $_data_log['app_passwd2'] = '*'; !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 (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) {
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) { if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
+3 -1
View File
@@ -498,10 +498,12 @@ function apppass_login($user, $pass, $extra = null, &$denied = false){
continue; continue;
} }
$key = strtolower($extra['service']) . '_access';
$mailbox_attributes = json_decode($row['mailbox_attributes'], true); $mailbox_attributes = json_decode($row['mailbox_attributes'], true);
// app passwords never grant a protocol the mailbox itself is denied
if ($row['active'] != '1' || $row['mailbox_active'] != '1' || $row['domain_active'] != '1' if ($row['active'] != '1' || $row['mailbox_active'] != '1' || $row['domain_active'] != '1'
|| ($mailbox_attributes['shared'] ?? '0') == '1' || ($mailbox_attributes['shared'] ?? '0') == '1'
|| ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1')) { || ($extra['service'] != 'NONE' && ($row[$key] != '1' || (isset($mailbox_attributes[$key]) && $mailbox_attributes[$key] != '1')))) {
// right password, but the app password does not allow this login // right password, but the app password does not allow this login
$denied = true; $denied = true;
continue; continue;
+34
View File
@@ -1857,6 +1857,40 @@ function get_tfa($username = null, $id = null) {
} }
} }
function verify_tfa_login($username, $_data) { 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 $pdo;
global $yubi; global $yubi;
global $u2f; global $u2f;
+3 -2
View File
@@ -767,8 +767,9 @@ function quarantine($_action, $_data = null) {
else { else {
$domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains')); $domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains'));
foreach ($domains as $domain) { 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'); // Compare the domain part exactly, a regex would treat dots in the domain as wildcards
$stmt->execute(array(':domain' => '@' . $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 SUBSTRING_INDEX(`rcpt`, "@", -1) = :domain');
$stmt->execute(array(':domain' => $domain));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
while($row = array_shift($rows)) { while($row = array_shift($rows)) {
$q_meta[] = $row; $q_meta[] = $row;
+6
View File
@@ -169,6 +169,12 @@ $SESSION_NAME = "MCSESSID";
// Label for OTP devices // Label for OTP devices
$OTP_LABEL = "mailcow UI"; $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 // How long to wait (in s) for cURL Docker requests
$DOCKER_TIMEOUT = 60; $DOCKER_TIMEOUT = 60;
+20 -1
View File
@@ -105,9 +105,28 @@ if (isset($_GET['query'])) {
'msg' => 'Cannot find attributes in post data' '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 {
// the WebAuthn login of a TFA account comes before the password update
$pending_allowed = array('get/passwordpolicy', 'get/webauthn-tfa-get-args', '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) { switch ($action) {
case "add": 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); http_response_code(403);
echo json_encode(array( echo json_encode(array(
'type' => 'error', 'type' => 'error',
+1
View File
@@ -641,6 +641,7 @@
"temp_error": "Temporärer Fehler", "temp_error": "Temporärer Fehler",
"text_empty": "Text darf nicht leer sein", "text_empty": "Text darf nicht leer sein",
"tfa_token_invalid": "TFA-Token ungültig", "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.", "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_dest_invalid": "Ziel ist ungültig",
"tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits", "tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits",
+1
View File
@@ -517,6 +517,7 @@
"group_send_as_invalid": "Send-as address %s is invalid or unknown", "group_send_as_invalid": "Send-as address %s is invalid or unknown",
"iam_sync_mode_invalid": "Invalid sync mode", "iam_sync_mode_invalid": "Invalid sync mode",
"iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)", "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.", "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_domain_invalid": "Alias domain %s is invalid",
"alias_empty": "Alias address must not be empty", "alias_empty": "Alias address must not be empty",
+1 -1
View File
@@ -39,7 +39,7 @@ if (isset($_GET['only_email'])) {
$onlyEmailAccount = false; $onlyEmailAccount = false;
$description = 'IMAP, CalDAV, CardDAV'; $description = 'IMAP, CalDAV, CardDAV';
} }
if (isset($_GET['app_password'])) { if (isset($_GET['app_password']) && ($_SESSION['acl']['app_passwds'] ?? '0') == '1') {
$app_password = true; $app_password = true;
$description .= ' with application password'; $description .= ' with application password';
-5
View File
@@ -12,11 +12,6 @@ $session_var_pass = 'sogo-sso-pass';
// external clients can never supply it (bare fastcgi param, not an HTTP header) // external clients can never supply it (bare fastcgi param, not an HTTP header)
$is_internal_auth = (($_SERVER['SOGO_AUTH_INTERNAL'] ?? '') === '1'); $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 // validate credentials for basic auth requests
if ($is_internal_auth && isset($_SERVER['PHP_AUTH_USER'])) { if ($is_internal_auth && isset($_SERVER['PHP_AUTH_USER'])) {
// load prerequisites only when required // load prerequisites only when required
@@ -77,6 +77,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if acl.app_passwds == 1 %}
<div class="row mt-2"> <div class="row mt-2">
<div class="col-12 col-md-3 d-flex"> <div class="col-12 col-md-3 d-flex">
<span class="mt-2 w-100 text-md-end">{{ lang.user.apple_connection_profile }}<br>{{ lang.user.with_app_password }}:</span> <span class="mt-2 w-100 text-md-end">{{ lang.user.apple_connection_profile }}<br>{{ lang.user.with_app_password }}:</span>
@@ -94,6 +95,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %}
<div class="row"> <div class="row">
<div class="col-12 d-flex flex-column text-center align-items-center mt-4"> <div class="col-12 d-flex flex-column text-center align-items-center mt-4">
<a target="_blank" href="https://mailcow.github.io/mailcow-dockerized-docs/client/client/#{{ clientconfigstr }}">{{ lang.user.client_configuration }}</a> <a target="_blank" href="https://mailcow.github.io/mailcow-dockerized-docs/client/client/#{{ clientconfigstr }}">{{ lang.user.client_configuration }}</a>
+2 -1
View File
@@ -428,7 +428,7 @@ services:
- php-fpm-mailcow - php-fpm-mailcow
- sogo-mailcow - sogo-mailcow
- rspamd-mailcow - rspamd-mailcow
image: ghcr.io/mailcow/nginx:1.30.6-1 image: ghcr.io/mailcow/nginx:1.30.6-2
dns: dns:
- ${IPV4_NETWORK:-172.22.1}.254 - ${IPV4_NETWORK:-172.22.1}.254
environment: environment:
@@ -448,6 +448,7 @@ services:
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1} - IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n} - NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-} - TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
- NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE:-128m}
volumes: volumes:
- ./data/web:/web:ro,z - ./data/web:/web:ro,z
- ./data/conf/rspamd/dynmaps:/dynmaps:ro,z - ./data/conf/rspamd/dynmaps:/dynmaps:ro,z
+4
View File
@@ -215,6 +215,10 @@ HTTPS_BIND=
# Redirect HTTP connections to HTTPS - y/n # Redirect HTTP connections to HTTPS - y/n
HTTP_REDIRECT=y 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 # Other bindings
# ------------------------------ # ------------------------------