mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-05 16:08:21 +00:00
Merge branch 'fix/sec' into fix/rework-bugs
This commit is contained in:
22 files changed
+212
-104
No files matched your search
+42
-43
@@ -144,41 +144,15 @@ try {
|
||||
}
|
||||
|
||||
$username = trim((string)$email);
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
|
||||
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
|
||||
WHERE `mailbox`.`username` = :username
|
||||
AND `mailbox`.`active` = '1'
|
||||
AND `domain`.`active` = '1'");
|
||||
$stmt->execute(array(':username' => $username));
|
||||
$MailboxData = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
catch(PDOException $e) {
|
||||
// Database error - return error response with complete XML
|
||||
list($usec, $sec) = explode(' ', microtime());
|
||||
?>
|
||||
<Response>
|
||||
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
|
||||
<ErrorCode>500</ErrorCode>
|
||||
<Message>Database Error</Message>
|
||||
<DebugData />
|
||||
</Error>
|
||||
</Response>
|
||||
</Autodiscover>
|
||||
<?php
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Mailbox not found or not active - return generic error to prevent user enumeration
|
||||
if (empty($MailboxData)) {
|
||||
if (!filter_var($username, FILTER_VALIDATE_EMAIL)) {
|
||||
try {
|
||||
$json = json_encode(
|
||||
array(
|
||||
"time" => time(),
|
||||
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
||||
"user" => $email,
|
||||
"user" => "none",
|
||||
"ip" => $_SERVER['REMOTE_ADDR'],
|
||||
"service" => "Error: mailbox not found or inactive"
|
||||
"service" => "Error: invalid email address in request"
|
||||
)
|
||||
);
|
||||
$redis->lPush('AUTODISCOVER_LOG', $json);
|
||||
@@ -202,13 +176,33 @@ if (empty($MailboxData)) {
|
||||
<?php
|
||||
exit(0);
|
||||
}
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
|
||||
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
|
||||
WHERE `mailbox`.`username` = :username
|
||||
AND `mailbox`.`active` = '1'
|
||||
AND `domain`.`active` = '1'");
|
||||
$stmt->execute(array(':username' => $username));
|
||||
$MailboxData = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
catch(PDOException $e) {
|
||||
// Database error - return error response with complete XML
|
||||
list($usec, $sec) = explode(' ', microtime());
|
||||
?>
|
||||
<Response>
|
||||
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
|
||||
<ErrorCode>500</ErrorCode>
|
||||
<Message>Database Error</Message>
|
||||
<DebugData />
|
||||
</Error>
|
||||
</Response>
|
||||
</Autodiscover>
|
||||
<?php
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (!empty($MailboxData['name'])) {
|
||||
$displayname = $MailboxData['name'];
|
||||
}
|
||||
else {
|
||||
$displayname = $email;
|
||||
}
|
||||
$email = $username;
|
||||
$displayname = $email;
|
||||
try {
|
||||
$json = json_encode(
|
||||
array(
|
||||
@@ -216,11 +210,16 @@ try {
|
||||
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
||||
"user" => $email,
|
||||
"ip" => $_SERVER['REMOTE_ADDR'],
|
||||
"service" => $autodiscover_config['autodiscoverType']
|
||||
// The answer is the same, only the log tells unknown or inactive mailboxes apart
|
||||
"service" => (empty($MailboxData)) ? "Error: mailbox not found or inactive" : $autodiscover_config['autodiscoverType']
|
||||
)
|
||||
);
|
||||
$redis->lPush('AUTODISCOVER_LOG', $json);
|
||||
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
|
||||
if (empty($MailboxData)) {
|
||||
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
|
||||
error_log("Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
|
||||
}
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
@@ -243,7 +242,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
||||
<Server><?=$autodiscover_config['imap']['server'];?></Server>
|
||||
<Port><?=$autodiscover_config['imap']['port'];?></Port>
|
||||
<DomainRequired>off</DomainRequired>
|
||||
<LoginName><?=$email;?></LoginName>
|
||||
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||
<SPA>off</SPA>
|
||||
<SSL>on</SSL>
|
||||
<AuthRequired>on</AuthRequired>
|
||||
@@ -253,7 +252,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
||||
<Server><?=$autodiscover_config['smtp']['server'];?></Server>
|
||||
<Port><?=$autodiscover_config['smtp']['port'];?></Port>
|
||||
<DomainRequired>off</DomainRequired>
|
||||
<LoginName><?=$email;?></LoginName>
|
||||
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||
<SPA>off</SPA>
|
||||
<SSL>on</SSL>
|
||||
<AuthRequired>on</AuthRequired>
|
||||
@@ -265,15 +264,15 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
||||
?>
|
||||
<Protocol>
|
||||
<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>
|
||||
<LoginName><?=$email;?></LoginName>
|
||||
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||
</Protocol>
|
||||
<Protocol>
|
||||
<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>
|
||||
<LoginName><?=$email;?></LoginName>
|
||||
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||
</Protocol>
|
||||
<?php
|
||||
}
|
||||
@@ -288,7 +287,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
||||
<Culture>en:en</Culture>
|
||||
<User>
|
||||
<DisplayName><?=htmlspecialchars($displayname, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></DisplayName>
|
||||
<EMailAddress><?=$email;?></EMailAddress>
|
||||
<EMailAddress><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></EMailAddress>
|
||||
</User>
|
||||
<Action>
|
||||
<Settings>
|
||||
|
||||
+2
-1
@@ -227,7 +227,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
||||
if (isset($_GET['syncjob']) &&
|
||||
is_numeric($_GET['syncjob'])) {
|
||||
$id = $_GET["syncjob"];
|
||||
$result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id);
|
||||
// Without the syncjobs ACL a job cannot be saved, so do not show the form
|
||||
$result = (($_SESSION['acl']['syncjobs'] ?? '0') == '1') ? \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id) : [];
|
||||
$template = 'edit/syncjob.twig';
|
||||
}
|
||||
elseif (isset($_GET['syncjob_source']) &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+20
-1
@@ -105,9 +105,28 @@ if (isset($_GET['query'])) {
|
||||
'msg' => 'Cannot find attributes in post data'
|
||||
));
|
||||
|
||||
// A login with pending 2FA setup or password update may only use the calls of these steps
|
||||
if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
|
||||
if (!empty($_SESSION['pending_tfa_setup'])) {
|
||||
$pending_allowed = array('get/passwordpolicy', 'get/webauthn-tfa-registration', 'add/webauthn-tfa-registration');
|
||||
}
|
||||
else {
|
||||
// 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) {
|
||||
case "add":
|
||||
if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username'])) {
|
||||
if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username']) || !isset($_SESSION["mailcow_cc_username"])) {
|
||||
http_response_code(403);
|
||||
echo json_encode(array(
|
||||
'type' => 'error',
|
||||
|
||||
@@ -641,6 +641,7 @@
|
||||
"temp_error": "Temporärer Fehler",
|
||||
"text_empty": "Text darf nicht leer sein",
|
||||
"tfa_token_invalid": "TFA-Token ungültig",
|
||||
"tfa_login_locked": "Zu viele fehlgeschlagene Zwei-Faktor-Anmeldungen, bitte später erneut versuchen",
|
||||
"tfa_removal_blocked": "Zwei-Faktor-Authentifizierung kann nicht entfernt werden, da sie für Ihr Konto erforderlich ist.",
|
||||
"tls_policy_map_dest_invalid": "Ziel ist ungültig",
|
||||
"tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits",
|
||||
|
||||
@@ -517,6 +517,7 @@
|
||||
"group_send_as_invalid": "Send-as address %s is invalid or unknown",
|
||||
"iam_sync_mode_invalid": "Invalid sync mode",
|
||||
"iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)",
|
||||
"tfa_login_locked": "Too many failed two-factor logins, please try again later",
|
||||
"tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.",
|
||||
"alias_domain_invalid": "Alias domain %s is invalid",
|
||||
"alias_empty": "Alias address must not be empty",
|
||||
|
||||
@@ -39,7 +39,7 @@ if (isset($_GET['only_email'])) {
|
||||
$onlyEmailAccount = false;
|
||||
$description = 'IMAP, CalDAV, CardDAV';
|
||||
}
|
||||
if (isset($_GET['app_password'])) {
|
||||
if (isset($_GET['app_password']) && ($_SESSION['acl']['app_passwds'] ?? '0') == '1') {
|
||||
$app_password = true;
|
||||
$description .= ' with application password';
|
||||
|
||||
|
||||
@@ -12,11 +12,6 @@ $session_var_pass = 'sogo-sso-pass';
|
||||
// external clients can never supply it (bare fastcgi param, not an HTTP header)
|
||||
$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
|
||||
if ($is_internal_auth && isset($_SERVER['PHP_AUTH_USER'])) {
|
||||
// load prerequisites only when required
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if acl.app_passwds == 1 %}
|
||||
<div class="row mt-2">
|
||||
<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>
|
||||
@@ -94,6 +95,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user