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
+212 -104

No files matched your search

+9
View File
@@ -74,6 +74,15 @@ function admin($_action, $_data = null) {
$usernames = $_data['username'];
}
foreach ($usernames as $username) {
// A pending password update only allows changing the own password
if (!empty($_SESSION['pending_pw_update']) && $username != $_SESSION['mailcow_cc_username']) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => 'access_denied'
);
continue;
}
$is_now = admin('details', $username);
if (!empty($is_now)) {
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
@@ -5,6 +5,15 @@ function app_passwd($_action, $_data = null) {
$_data_log = $_data;
!isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*';
!isset($_data_log['app_passwd2']) ?: $_data_log['app_passwd2'] = '*';
if (in_array($_action, array('add', 'edit', 'delete')) &&
(!isset($_SESSION['acl']['app_passwds']) || $_SESSION['acl']['app_passwds'] != "1")) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => 'access_denied'
);
return false;
}
if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) {
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
$_SESSION['return'][] = array(
+3 -1
View File
@@ -498,10 +498,12 @@ function apppass_login($user, $pass, $extra = null, &$denied = false){
continue;
}
$key = strtolower($extra['service']) . '_access';
$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'
|| ($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
$denied = true;
continue;
+34
View File
@@ -1857,6 +1857,40 @@ function get_tfa($username = null, $id = null) {
}
}
function verify_tfa_login($username, $_data) {
global $redis;
global $TFA_MAX_FAILS;
global $TFA_FAIL_WINDOW;
if (empty($username)) {
return false;
}
// Count failed second factors per account, so codes cannot be guessed from many addresses
$fails_key = 'TFA_FAILS_' . $username;
if (intval($redis->get($fails_key)) >= $TFA_MAX_FAILS) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $username, '*'),
'msg' => 'tfa_login_locked'
);
$verified = false;
}
else {
$verified = verify_tfa_token($username, $_data);
}
if ($verified !== true) {
// The window starts with the first failure and is not extended by further attempts
if ($redis->incr($fails_key) == 1) {
$redis->expire($fails_key, $TFA_FAIL_WINDOW);
}
// Same message format as failed passwords, so the default fail2ban rule counts it
error_log("mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']);
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']);
return false;
}
$redis->del($fails_key);
return true;
}
function verify_tfa_token($username, $_data) {
global $pdo;
global $yubi;
global $u2f;
+3 -2
View File
@@ -767,8 +767,9 @@ function quarantine($_action, $_data = null) {
else {
$domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains'));
foreach ($domains as $domain) {
$stmt = $pdo->prepare('SELECT `id`, `qid`, `subject`, LOCATE("VIRUS_FOUND", `symbols`) AS `virus_flag`, `score`, `rcpt`, `sender`, `action`, UNIX_TIMESTAMP(`created`) AS `created`, `notified` FROM `quarantine` WHERE `rcpt` REGEXP :domain');
$stmt->execute(array(':domain' => '@' . $domain . '$'));
// Compare the domain part exactly, a regex would treat dots in the domain as wildcards
$stmt = $pdo->prepare('SELECT `id`, `qid`, `subject`, LOCATE("VIRUS_FOUND", `symbols`) AS `virus_flag`, `score`, `rcpt`, `sender`, `action`, UNIX_TIMESTAMP(`created`) AS `created`, `notified` FROM `quarantine` WHERE SUBSTRING_INDEX(`rcpt`, "@", -1) = :domain');
$stmt->execute(array(':domain' => $domain));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
while($row = array_shift($rows)) {
$q_meta[] = $row;
+6
View File
@@ -169,6 +169,12 @@ $SESSION_NAME = "MCSESSID";
// Label for OTP devices
$OTP_LABEL = "mailcow UI";
// Failed second factor logins (TOTP, Yubico OTP, WebAuthn) of an account before its second factor is locked
$TFA_MAX_FAILS = 10;
// Time (in s) from the first failed second factor login until the counter and the lock are reset
$TFA_FAIL_WINDOW = 900;
// How long to wait (in s) for cURL Docker requests
$DOCKER_TIMEOUT = 60;