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 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/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 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/data/conf/nginx/templates/nginx.conf.j2 b/data/conf/nginx/templates/nginx.conf.j2 index 08a85a144..861c7e560 100644 --- a/data/conf/nginx/templates/nginx.conf.j2 +++ b/data/conf/nginx/templates/nginx.conf.j2 @@ -42,12 +42,19 @@ http { https https; } + map $server_port $sogo_auth_internal { + 65510 "1"; + default ""; + } + {% if HTTP_REDIRECT %} # HTTP to HTTPS redirect 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(' ') }}; 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/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; diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index 77c58b609..c86561903 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: @@ -4908,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"] @@ -5834,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"] @@ -5856,7 +5870,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 +5887,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/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/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( diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 89f14b574..dfd860a9b 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -53,6 +53,39 @@ 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; +} +// 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) { @@ -2283,10 +2316,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 +2330,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; @@ -2342,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'); diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index a9f17705d..acbbb94d4 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)) { @@ -5320,6 +5323,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"); @@ -5647,8 +5658,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 +6058,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 +6218,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)"); 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; 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, diff --git a/data/web/js/site/dashboard.js b/data/web/js/site/dashboard.js index aee361710..d591f8ac0 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(){ @@ -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"; } @@ -1669,41 +1676,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); 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 = ''; 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/sogo-auth.php b/data/web/sogo-auth.php index 07456a7d1..bc9694d8e 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'; @@ -50,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: /"); @@ -80,7 +89,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')) { 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 30a7ba96b..4742fef17 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 @@ -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-1 dns: - ${IPV4_NETWORK:-172.22.1}.254 environment: @@ -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: