diff --git a/_modules/scripts/ipv6_controller.sh b/_modules/scripts/ipv6_controller.sh index a025ee178..689c601b2 100644 --- a/_modules/scripts/ipv6_controller.sh +++ b/_modules/scripts/ipv6_controller.sh @@ -3,6 +3,11 @@ # THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY! # DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!! +# Global IPv6 address that is no unique local address (fc00::/7, e.g. Docker bridges) +_has_public_ipv6() { + ip -6 -o addr show scope global 2>/dev/null | awk '{print $4}' | grep -qviE '^f[cd]' +} + # 1) Check if the host supports IPv6 get_ipv6_support() { # ---- helper: probe external IPv6 connectivity without DNS ---- @@ -37,6 +42,9 @@ get_ipv6_support() { if _probe_ipv6_connectivity; then DETECTED_IPV6=true echo -e "IPv6 detected on host – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}" + elif _has_public_ipv6; then + DETECTED_IPV6=true + echo -e "${YELLOW}External IPv6 connectivity test failed, but the host has a public IPv6 address – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}" else DETECTED_IPV6=false echo -e "${YELLOW}Default IPv6 route present but external IPv6 connectivity failed – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}" @@ -192,9 +200,31 @@ EOF fi } +_set_enable_ipv6() { + if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then + if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then + sed -i "s/^ENABLE_IPV6=.*/ENABLE_IPV6=$1/" "$MAILCOW_CONF" + else + echo "ENABLE_IPV6=$1" >> "$MAILCOW_CONF" + fi + else + export IPV6_BOOL=$1 + fi +} + +_warn_ipv6_disabled() { + _has_public_ipv6 || return 0 + ip -6 route show default 2>/dev/null | grep -qE '^default' || return 0 + grep -Eq '"userland-proxy"[[:space:]]*:[[:space:]]*false' /etc/docker/daemon.json 2>/dev/null && return 0 + echo -e "${LIGHT_RED}WARNING: ENABLE_IPV6=false, but this host has a public IPv6 address.${NC}" + echo -e "${YELLOW}Docker forwards IPv6 connections to mailcow with an internal source address, which Postfix trusts. This can make your server an open relay over IPv6.${NC}" + echo -e "${YELLOW}Set ENABLE_IPV6=true in mailcow.conf, or set \"userland-proxy\": false in /etc/docker/daemon.json and restart Docker.${NC}" + echo -e "${YELLOW}See https://docs.mailcow.email/post_installation/firststeps-disable_ipv6/${NC}" + sleep 5 +} + # 3) Main wrapper for generate_config.sh and update.sh configure_ipv6() { - # detect manual override if mailcow.conf is present if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]] && grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then MANUAL_SETTING=$(grep '^ENABLE_IPV6=' "$MAILCOW_CONF" | cut -d= -f2) elif [[ -z "$MAILCOW_CONF" ]] && [[ -n "${ENABLE_IPV6:-}" ]]; then @@ -203,19 +233,23 @@ configure_ipv6() { MANUAL_SETTING="" fi + if [[ "${MANUAL_SETTING,,}" == "false" ]]; then + _set_enable_ipv6 false + echo "ENABLE_IPV6=false is set, skipping Docker IPv6 configuration." + _warn_ipv6_disabled + echo "IPv6 configuration complete: ENABLE_IPV6=false" + return + fi + get_ipv6_support - # if user manually set it, check for mismatch + if [[ "$DETECTED_IPV6" != "true" && "${MANUAL_SETTING,,}" == "true" ]] && _has_public_ipv6; then + echo -e "${YELLOW}Keeping ENABLE_IPV6=true, the host has a public IPv6 address.${NC}" + DETECTED_IPV6=true + fi + if [[ "$DETECTED_IPV6" != "true" ]]; then - if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then - if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then - sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=false/' "$MAILCOW_CONF" - else - echo "ENABLE_IPV6=false" >> "$MAILCOW_CONF" - fi - else - export IPV6_BOOL=false - fi + _set_enable_ipv6 false echo "Skipping Docker IPv6 configuration because host does not support IPv6." echo "Make sure to check if your docker daemon.json does not include \"enable_ipv6\": true if you do not want IPv6." echo "IPv6 configuration complete: ENABLE_IPV6=false" @@ -224,16 +258,7 @@ configure_ipv6() { fi docker_daemon_edit - - if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then - if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then - sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=true/' "$MAILCOW_CONF" - else - echo "ENABLE_IPV6=true" >> "$MAILCOW_CONF" - fi - else - export IPV6_BOOL=true - fi + _set_enable_ipv6 true echo "IPv6 configuration complete: ENABLE_IPV6=true" } \ No newline at end of file diff --git a/data/Dockerfiles/dockerapi/modules/DockerApi.py b/data/Dockerfiles/dockerapi/modules/DockerApi.py index 414096c64..dd9b056ac 100644 --- a/data/Dockerfiles/dockerapi/modules/DockerApi.py +++ b/data/Dockerfiles/dockerapi/modules/DockerApi.py @@ -621,8 +621,8 @@ class DockerApi: if not user or id in ('user=', 'group='): return Response(content=json.dumps({ 'type': 'danger', 'msg': 'user and id required' }, indent=4), media_type="application/json") action = 'doveadm acl set -u "$ACL_USER" -- "$folder" "$ACL_ID" $ACL_RIGHTS' if rights else 'doveadm acl delete -u "$ACL_USER" -- "$folder" "$ACL_ID"' - script = ('set -o pipefail; doveadm mailbox list -u "$ACL_USER" | while IFS= read -r folder; do ' - 'case "$folder" in Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"') + script = ('set -o pipefail; doveadm -f tab mailbox status -u "$ACL_USER" guid "*" | tail -n +2 | cut -f1 | while IFS= read -r folder; do ' + 'case "$folder" in Shared|Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"') acl_return = container.exec_run(["/bin/bash", "-c", script], environment={ 'ACL_USER': user, 'ACL_ID': id, 'ACL_RIGHTS': ' '.join(rights) }) return self.exec_run_handler('generic', acl_return) diff --git a/data/Dockerfiles/dovecot/trim_logs.sh b/data/Dockerfiles/dovecot/trim_logs.sh index 3b5e05278..748c80a2a 100755 --- a/data/Dockerfiles/dovecot/trim_logs.sh +++ b/data/Dockerfiles/dovecot/trim_logs.sh @@ -4,7 +4,7 @@ catch_non_zero() { ${CMD} > /dev/null EC=$? if [ ${EC} -ne 0 ]; then - echo "Command ${CMD} failed to execute, exit code was ${EC}" + echo "Command ${CMD//${REDISPASS}/***} failed to execute, exit code was ${EC}" fi } source /source_env.sh diff --git a/data/Dockerfiles/nginx/Dockerfile b/data/Dockerfiles/nginx/Dockerfile index c4230482a..8d1b004a9 100644 --- a/data/Dockerfiles/nginx/Dockerfile +++ b/data/Dockerfiles/nginx/Dockerfile @@ -3,7 +3,8 @@ LABEL maintainer "The Infrastructure Company GmbH " ENV PIP_BREAK_SYSTEM_PACKAGES=1 -RUN apk add --no-cache nginx \ +RUN apk upgrade --no-cache && \ + apk add --no-cache nginx \ python3 \ py3-pip && \ pip install --upgrade pip && \ diff --git a/data/Dockerfiles/postfix-tlspol/Dockerfile b/data/Dockerfiles/postfix-tlspol/Dockerfile index 51e8f775d..0f792a074 100644 --- a/data/Dockerfiles/postfix-tlspol/Dockerfile +++ b/data/Dockerfiles/postfix-tlspol/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /src ENV CGO_ENABLED=0 \ GO111MODULE=on \ NOOPT=1 \ - VERSION=1.14.0 + VERSION=1.14.5 RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \ cd /src/postfix-tlspol && \ diff --git a/data/Dockerfiles/rspamd/docker-entrypoint.sh b/data/Dockerfiles/rspamd/docker-entrypoint.sh index a764725cb..0198a3969 100755 --- a/data/Dockerfiles/rspamd/docker-entrypoint.sh +++ b/data/Dockerfiles/rspamd/docker-entrypoint.sh @@ -277,9 +277,34 @@ if [[ ! -z ${SPAMHAUS_DQS_KEY} ]]; then } } "DBL" { - # override the defaults for DBL defined in modules.d/rbl.conf + # full copy of the DBL rule from modules.d/rbl.conf, pointed at DQS: + # the override include replaces the stock rule instead of merging into it rbl = "${SPAMHAUS_DQS_KEY}.dbl.dq.spamhaus.net"; disable_monitoring = true; + checks = ["emails", "dkim", "helo", "rdns", "replyto", "urls", "content_urls"]; + no_ip = true; + ignore_defaults = true; + exclude_users = false; + emails_domainonly = true; + selector = { + from_smtp = "from('smtp'):domain"; + from_mime = "from('mime'):domain"; + mid = "header(Message-Id).regexp('@([^\.]+\.[^>]+)').last"; + } + returncodes = { + DBL_SPAM = "127.0.1.2"; + DBL_PHISH = "127.0.1.4"; + DBL_MALWARE = "127.0.1.5"; + DBL_BOTNET = "127.0.1.6"; + DBL_ABUSE = "127.0.1.102"; + DBL_ABUSE_REDIR = "127.0.1.103"; + DBL_ABUSE_PHISH = "127.0.1.104"; + DBL_ABUSE_MALWARE = "127.0.1.105"; + DBL_ABUSE_BOTNET = "127.0.1.106"; + DBL_PROHIBIT = "127.0.1.255"; + DBL_BLOCKED_OPENRESOLVER = "127.255.255.254"; + DBL_BLOCKED = "127.255.255.255"; + } } "ZRD" { ignore_defaults = true; diff --git a/data/conf/dovecot/auth/mailcowauth.php b/data/conf/dovecot/auth/mailcowauth.php index 9a4637506..7d4424dc5 100644 --- a/data/conf/dovecot/auth/mailcowauth.php +++ b/data/conf/dovecot/auth/mailcowauth.php @@ -116,8 +116,8 @@ if ($result === false){ exit; } if ($result) { - error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip); - set_sasl_log($post['username'], $client_ip, $post['service']); + error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip); + set_sasl_log($post['username'], $client_ip, $service); } } diff --git a/data/conf/nginx/templates/sites-default.conf.j2 b/data/conf/nginx/templates/sites-default.conf.j2 index 50a24f247..9db8a2e70 100644 --- a/data/conf/nginx/templates/sites-default.conf.j2 +++ b/data/conf/nginx/templates/sites-default.conf.j2 @@ -94,11 +94,11 @@ location / { } location /qhandler { - rewrite ^/qhandler/(.*)/(.*) /qhandler.php?action=$1&hash=$2; + rewrite ^/qhandler/([^/]+)/(.+?)/?$ /qhandler.php?action=$1&hash=$2; } location /edit { - rewrite ^/edit/(.*)/(.*) /edit.php?$1=$2; + rewrite ^/edit/([^/]+)/(.+?)/?$ /edit.php?$1=$2; } location ~ ^/api/v1/(.*)$ { @@ -187,6 +187,12 @@ location /sogo-auth-verify { } location ^~ /Microsoft-Server-ActiveSync { + # Outlook for iOS/Android (Microsoft's sync service) sends EAS commands without credentials + # and only authenticates after a 401 Basic challenge. SOGo answers those with 403, so challenge here. + if ($http_authorization = "") { + add_header WWW-Authenticate 'Basic realm="SOGo"' always; + return 401; + } auth_request /sogo-auth-verify; auth_request_set $user $upstream_http_x_user; auth_request_set $auth $upstream_http_x_auth; diff --git a/data/conf/rspamd/local.d/composites.conf b/data/conf/rspamd/local.d/composites.conf index 4ce042aab..cad77f251 100644 --- a/data/conf/rspamd/local.d/composites.conf +++ b/data/conf/rspamd/local.d/composites.conf @@ -1,7 +1,3 @@ -MX_IMPLICIT { - expression = "MX_GOOD & MX_MISSING"; - score = -0.01; -} VIRUS_FOUND { expression = "CLAM_VIRUS & !MAILCOW_WHITE"; score = 2000.0; diff --git a/data/conf/rspamd/local.d/groups.conf b/data/conf/rspamd/local.d/groups.conf index f77d8a463..bc4eb0bae 100644 --- a/data/conf/rspamd/local.d/groups.conf +++ b/data/conf/rspamd/local.d/groups.conf @@ -23,24 +23,6 @@ symbols { } } -group "MX" { - "MX_INVALID" { - score = 0.5; - description = "No connectable MX"; - one_shot = true; - } - "MX_MISSING" { - score = 2.0; - description = "No MX record"; - one_shot = true; - } - "MX_GOOD" { - score = -0.01; - description = "MX was ok"; - one_shot = true; - } -} - group "reputation" { symbols = { "IP_REPUTATION_HAM" { diff --git a/data/conf/rspamd/local.d/multimap.conf b/data/conf/rspamd/local.d/multimap.conf index 888bf3630..210d78f94 100644 --- a/data/conf/rspamd/local.d/multimap.conf +++ b/data/conf/rspamd/local.d/multimap.conf @@ -5,12 +5,6 @@ RCPT_MAILCOW_DOMAIN { symbols_set = ["RCPT_MAILCOW_DOMAIN"]; } -WHITELISTED_FWD_HOST { - type = "ip"; - map = "redis://WHITELISTED_FWD_HOST"; - symbols_set = ["WHITELISTED_FWD_HOST"]; -} - BULK_HEADER { type = "content"; map = "${LOCAL_CONFDIR}/custom/bulk_header.map"; @@ -151,20 +145,6 @@ BAD_LANG { score = 5.0; } -BAZAAR_ABUSE_CH { - type = "selector"; - selector = "attachments(hex,md5)"; - map = "https://bazaar.abuse.ch/export/txt/md5/recent/"; - score = 10.0; -} - -URLHAUS_ABUSE_CH { - type = "selector"; - selector = "urls"; - map = "https://urlhaus.abuse.ch/downloads/text_online/"; - score = 10.0; -} - SMTP_LIMITED_ACCESS { type = "user"; map = "redis://SMTP_LIMITED_ACCESS"; diff --git a/data/conf/rspamd/local.d/mx_check.conf b/data/conf/rspamd/local.d/mx_check.conf index 22fcedf19..d421a9803 100644 --- a/data/conf/rspamd/local.d/mx_check.conf +++ b/data/conf/rspamd/local.d/mx_check.conf @@ -1,6 +1,5 @@ timeout = 8.0; symbol_bad_mx = "MX_INVALID"; -symbol_no_mx = "MX_MISSING"; symbol_good_mx = "MX_GOOD"; expire = 86400; key_prefix = "rmx"; diff --git a/data/conf/rspamd/local.d/options.inc b/data/conf/rspamd/local.d/options.inc index f83ddf0fc..338749351 100644 --- a/data/conf/rspamd/local.d/options.inc +++ b/data/conf/rspamd/local.d/options.inc @@ -9,3 +9,5 @@ disable_monitoring = true; # instead of silently accepting the message without further processing. soft_reject_on_timeout = true; local_addrs = /etc/rspamd/custom/mailcow_networks.map; +# Must not be lower than message_size_limit in postfix main.cf +max_message = 134217728; diff --git a/data/conf/rspamd/lua/rspamd.local.lua b/data/conf/rspamd/lua/rspamd.local.lua index b0903a372..45f958431 100644 --- a/data/conf/rspamd/lua/rspamd.local.lua +++ b/data/conf/rspamd/lua/rspamd.local.lua @@ -362,6 +362,61 @@ rspamd_config:register_symbol({ priority = 19 }) +rspamd_config:register_symbol({ + name = 'WHITELISTED_FWD_HOST', + type = 'prefilter', + score = 0.0, + callback = function(task) + local rspamd_logger = require "rspamd_logger" + local redis_params = rspamd_parse_redis_server('fwd_host') + local ip = task:get_from_ip() + + if ip == nil or not ip:is_valid() then + return false + end + + local from_ip_string = tostring(ip) + local ip_check_table = {'WHITELISTED_FWD_HOST', from_ip_string} + + local maxbits = 128 + local minbits = 7 + if ip:get_version() == 4 then + maxbits = 32 + minbits = 8 + end + for i=maxbits,minbits,-1 do + table.insert(ip_check_table, ip:apply_mask(i):to_string() .. "/" .. i) + end + + local function fwd_host_cb(err, data) + if err or type(data) ~= 'table' then + rspamd_logger.infox(rspamd_config, "fwd_host query request for ip %s returned invalid or empty data (\"%s\") or error (\"%s\")", ip, data, err) + return + end + for _,v in pairs(data) do + if type(v) == 'string' then + task:insert_result('WHITELISTED_FWD_HOST', 1.0, from_ip_string) + task:set_flag('pass_all') + return + end + end + end + + local redis_ret = rspamd_redis_make_request(task, + redis_params, -- connect params + 'WHITELISTED_FWD_HOST', -- hash key + false, -- is write + fwd_host_cb, --callback + 'HMGET', -- command + ip_check_table -- arguments + ) + if not redis_ret then + rspamd_logger.infox(rspamd_config, "cannot check fwd_host redis map") + end + end, + priority = 18 +}) + rspamd_config:register_symbol({ name = 'TLS_HEADER', type = 'postfilter', @@ -390,9 +445,8 @@ rspamd_config:register_symbol({ local rspamd_logger = require "rspamd_logger" local redis_params = rspamd_parse_redis_server('taghandler') local rspamd_http = require "rspamd_http" - local rcpts = task:get_recipients('smtp') + local rcpts = task:get_recipients({'smtp', 'orig'}) local lua_util = require "lua_util" - local tagged_rcpt = task:get_symbol("TAGGED_RCPT") local function remove_moo_tag() local moo_tag_header = task:get_header('X-Moo-Tag', false) @@ -417,8 +471,9 @@ rspamd_config:register_symbol({ -- Check if recipient has a tag (contains '+') local tag = nil - if tagged_rcpt ~= nil then - tag = tagged_rcpt + local base_user, tag_part = rcpt_user:match('^([^%+]+)%+(.+)$') + if base_user and tag_part then + tag = tag_part rspamd_logger.infox("TAG_MOO: found tag in recipient: %s (base: %s, tag: %s)", rcpt_addr, base_user, tag) end @@ -498,8 +553,7 @@ rspamd_config:register_symbol({ else rspamd_logger.infox("TAG_MOO: user wants subject modified for tagged mail") local sbj = task:get_header('Subject') or '' - local tag_value = tag[1] and tag[1].options and tag[1].options[1] or '' - new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag_value .. '] ' .. sbj)) .. '?=' + new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag .. '] ' .. sbj)) .. '?=' task:set_milter_reply({ remove_headers = { ['Subject'] = 1, @@ -907,6 +961,9 @@ rspamd_config:register_symbol({ if not seen_cte and rewrite.need_rewrite_ct then out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', new_cte) + task:set_milter_reply({ + add_headers = {['Content-Transfer-Encoding'] = new_cte} + }) end -- End of headers diff --git a/data/conf/rspamd/meta_exporter/pipe.php b/data/conf/rspamd/meta_exporter/pipe.php index c0e056c6a..439238cc9 100644 --- a/data/conf/rspamd/meta_exporter/pipe.php +++ b/data/conf/rspamd/meta_exporter/pipe.php @@ -109,7 +109,7 @@ foreach ($rcpts as $rcpt) { } // Skip if domain is excluded - if (in_array($parsed_rcpt['domain'], $exclude_domains)) { + if (is_array($exclude_domains) && in_array($parsed_rcpt['domain'], $exclude_domains)) { error_log(sprintf("QUARANTINE: Skipped domain %s", $parsed_rcpt['domain']) . PHP_EOL); continue; } diff --git a/data/web/autodiscover-json.php b/data/web/autodiscover-json.php index 2fa96990e..661699293 100644 --- a/data/web/autodiscover-json.php +++ b/data/web/autodiscover-json.php @@ -8,7 +8,7 @@ if(file_exists('inc/vars.local.inc.php')) { $autodiscover_config = array_merge($default_autodiscover_config, $autodiscover_config); header('Content-type: application/json'); -if (strtolower($_GET['Protocol']) == 'activesync' && getenv('SKIP_SOGO') != "y") { +if (strtolower($_GET['Protocol']) == 'activesync' && $autodiscover_config['autodiscoverType'] == 'activesync' && getenv('SKIP_SOGO') != "y") { echo '{"Protocol":"ActiveSync","Url":"' . $autodiscover_config['activesync']['url'] . '"}'; } elseif (strtolower($_GET['Protocol']) == 'autodiscoverv1') { diff --git a/data/web/autodiscover.php b/data/web/autodiscover.php index e2d70bbae..8a9349934 100644 --- a/data/web/autodiscover.php +++ b/data/web/autodiscover.php @@ -31,7 +31,7 @@ error_reporting(0); $data = trim(file_get_contents("php://input")); -if (strpos($data, 'autodiscover/outlook/responseschema') !== false) { +if (strpos($data, 'autodiscover/outlook/responseschema') !== false && $autodiscover_config['autodiscoverType'] == 'activesync') { $autodiscover_config['autodiscoverType'] = 'imap'; if ($autodiscover_config['useEASforOutlook'] == 'yes' && // Office for macOS does not support EAS diff --git a/data/web/css/build/013-datatables.css b/data/web/css/build/013-datatables.css index 1bc18119e..21d5c9a54 100644 --- a/data/web/css/build/013-datatables.css +++ b/data/web/css/build/013-datatables.css @@ -15,6 +15,10 @@ table.dataTable>tbody>tr.child ul.dtr-details>li { border-bottom: 1px solid rgba(0, 0, 0, 0.129); padding: 0.5em 0; } +/* like the first entry has no top padding, the last one needs no bottom padding */ +table.dataTable>tbody>tr.child ul.dtr-details>li:last-child { + padding-bottom: 0; +} table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover, table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover { background-color: var(--mc-dtr-hover-bg); @@ -28,7 +32,8 @@ table.dataTable td.dt-control:before { color: var(--mc-dtr-color); height: 1em; width: 1em; - line-height: 1.25em; + line-height: 1em; + margin-top: calc(-0.5em - 1.5px); border-radius: 0px; box-shadow: none; font-size: 14px; @@ -39,11 +44,29 @@ table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before, table.dataTable td.dt-control:before { background-color: var(--mc-dtr-active-bg) !important; } +/* The default initial order targets column 0, which is usually the non-orderable expand column */ +table.dataTable thead > tr > th.sorting_disabled.sorting_asc, +table.dataTable thead > tr > th.sorting_disabled.sorting_desc { + padding-right: 0.5rem; + cursor: auto; +} +table.dataTable thead > tr > th.sorting_disabled.sorting_asc:before, +table.dataTable thead > tr > th.sorting_disabled.sorting_asc:after, +table.dataTable thead > tr > th.sorting_disabled.sorting_desc:before, +table.dataTable thead > tr > th.sorting_disabled.sorting_desc:after { + display: none; +} table.dataTable.table-striped>tbody>tr>td { vertical-align: middle; } table.dataTable.table-striped>tbody>tr>td>input[type="checkbox"] { - margin-top: 7px; + /* centers the 1em checkbox in the 1.5em line, level with the expand control */ + margin-top: 0.25em; +} +/* the vertical margin is meant for the tag editor in edit forms, in tables it only adds height */ +table.dataTable .tag-badge { + margin-top: 0; + margin-bottom: 0; } td.dtr-col-lg { min-width: 350px; diff --git a/data/web/css/build/014-mailcow.css b/data/web/css/build/014-mailcow.css index 52a34f8b5..99925ca28 100644 --- a/data/web/css/build/014-mailcow.css +++ b/data/web/css/build/014-mailcow.css @@ -139,6 +139,13 @@ body { display: inline-block; font-size: inherit; } +.badge .bi { + font-size: inherit; +} +/* keeps icons from making a badge taller than one with text only */ +.badge .bi::before { + line-height: 0; +} .btn-group-xs > .btn, .btn-xs { padding: .25rem .4rem; font-size: .875rem; @@ -343,6 +350,7 @@ code { } .tag-badge .bi { font-size: 12px; + line-height: 0; } .tag-badge.btn-badge:hover { filter: brightness(0.9); diff --git a/data/web/css/build/015-responsive.css b/data/web/css/build/015-responsive.css index 82be71069..d3e0263cd 100644 --- a/data/web/css/build/015-responsive.css +++ b/data/web/css/build/015-responsive.css @@ -191,7 +191,7 @@ height: 2rem; width: 2rem; line-height: 2rem; - margin-top: -15px; + margin-top: calc(-1rem - 1.5px); } li .dtr-data { diff --git a/data/web/inc/functions.auth.inc.php b/data/web/inc/functions.auth.inc.php index d5ace0d5a..832b88359 100644 --- a/data/web/inc/functions.auth.inc.php +++ b/data/web/inc/functions.auth.inc.php @@ -601,6 +601,13 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ // validate mailcow_password $mailcow_password = $user_res['attributes']['mailcow_password'][0]; + // Keycloak returns binary LDAP attributes like userPassword Base64 encoded + if (substr($mailcow_password, 0, 1) != '{') { + $decoded_password = base64_decode($mailcow_password, true); + if ($decoded_password !== false && substr($decoded_password, 0, 1) == '{') { + $mailcow_password = $decoded_password; + } + } if (!verify_hash($mailcow_password, $pass)) { return false; } diff --git a/data/web/inc/functions.fwdhost.inc.php b/data/web/inc/functions.fwdhost.inc.php index 52c751591..ff77fcc61 100644 --- a/data/web/inc/functions.fwdhost.inc.php +++ b/data/web/inc/functions.fwdhost.inc.php @@ -35,6 +35,13 @@ function fwdhost($_action, $_data = null) { ); return false; } + $hosts = array_unique(array_map(function ($host) { + $net = explode('/', $host, 2); + if (filter_var($net[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $net[0] = inet_ntop(inet_pton($net[0])); + } + return implode('/', $net); + }, $hosts)); foreach ($hosts as $host) { try { $redis->hSet('WHITELISTED_FWD_HOST', $host, $source); diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index c304e1b7c..c9bb9eb50 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -1399,6 +1399,18 @@ function set_tfa($_data) { case "webauthn": $key_id = (!isset($_data["key_id"])) ? 'unidentified' : $_data["key_id"]; + // Refuse incomplete WebAuthn registrations. Without this guard a request + // that does not contain the parsed credential data (e.g. a plain form + // POST) would silently insert an empty keyHandle while reporting success. + if (empty($_data['registration']) || empty($_data['registration']->credentialId)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_data_log), + 'msg' => 'webauthn_registration_failed' + ); + return false; + } + $stmt = $pdo->prepare("INSERT INTO `tfa` (`username`, `key_id`, `authmech`, `keyHandle`, `publicKey`, `certificate`, `counter`, `active`) VALUES (?, ?, 'webauthn', ?, ?, ?, ?, '1')"); $stmt->execute(array( @@ -1421,11 +1433,11 @@ function set_tfa($_data) { // Block TFA removal if force_tfa policy is active $is_forced_tfa = false; if ($_SESSION['mailcow_cc_role'] === 'user') { - $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?"); + $stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?"); $stmt_check->execute(array($username)); $is_forced_tfa = ($stmt_check->fetchColumn() == '1'); } else { - $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?"); + $stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?"); $stmt_check->execute(array($username)); $is_forced_tfa = ($stmt_check->fetchColumn() == '1'); } @@ -1692,11 +1704,11 @@ function unset_tfa_key($_data) { // Block key removal if force_tfa policy is active $is_forced_tfa = false; if ($_SESSION['mailcow_cc_role'] === 'user') { - $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?"); + $stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?"); $stmt_check->execute(array($username)); $is_forced_tfa = ($stmt_check->fetchColumn() == '1'); } else { - $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?"); + $stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?"); $stmt_check->execute(array($username)); $is_forced_tfa = ($stmt_check->fetchColumn() == '1'); } @@ -2425,7 +2437,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { case "use_ssl": case "use_tls": case "login_provisioning": - case "ignore_ssl_errors": + case "ignore_ssl_error": $settings[$row["key"]] = boolval($row["value"]); break; default: @@ -2449,7 +2461,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { if ($settings["authsource"] == "ldap"){ $settings['use_ssl'] = !isset($settings['use_ssl']) ? false : $settings['use_ssl']; $settings['use_tls'] = !isset($settings['use_tls']) ? false : $settings['use_tls']; - $settings['ignore_ssl_errors'] = !isset($settings['ignore_ssl_errors']) ? false : $settings['ignore_ssl_errors']; + $settings['ignore_ssl_error'] = !isset($settings['ignore_ssl_error']) ? false : $settings['ignore_ssl_error']; } return $settings; break; @@ -3111,7 +3123,17 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { case "get-keycloak-admin-token": // get access_token for service account of mailcow client if ($iam_settings['authsource'] !== 'keycloak') return false; - if (isset($iam_settings['access_token'])) { + // only introspect a cached access_token that is not (about to be) expired, + // introspecting an expired token makes Keycloak log INTROSPECT_TOKEN_ERROR + $token_not_expired = false; + if (!empty($iam_settings['access_token'])) { + $token_parts = explode('.', $iam_settings['access_token']); + if (count($token_parts) === 3) { + $token_payload = json_decode(base64_decode(strtr($token_parts[1], '-_', '+/')), true); + $token_not_expired = is_array($token_payload) && isset($token_payload['exp']) && intval($token_payload['exp']) > time() + 30; + } + } + if ($token_not_expired) { // check if access_token is valid $url = "{$iam_settings['server_url']}/realms/{$iam_settings['realm']}/protocol/openid-connect/token/introspect"; $req = http_build_query(array( @@ -3162,6 +3184,8 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { ':key' => 'access_token', ':value' => $res['access_token'] )); + // keep the cached token in sync, so subsequent calls in the same run reuse it + $iam_settings['access_token'] = $res['access_token']; return $res['access_token']; break; } diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index 3abdc920e..fee535d9a 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -2535,6 +2535,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':domain' => $domain, ':tag_name' => $tag, )); + $stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain"); + $stmt->execute(array( + ':domain' => $domain, + )); } $_SESSION['return'][] = array( @@ -2706,6 +2710,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':domain' => $domain, ':tag_name' => $tag, )); + $stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain"); + $stmt->execute(array( + ':domain' => $domain, + )); } $_SESSION['return'][] = array( @@ -3188,6 +3196,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':username' => $username, ':tag_name' => $tag, )); + $stmt = $pdo->prepare("UPDATE `mailbox` SET `modified` = NOW() WHERE `username` = :username"); + $stmt->execute(array( + ':username' => $username, + )); } catch (Exception $e) { } } @@ -4011,6 +4023,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array(':goto' => '(^|,)'.preg_quote($_data, '/').'($|,)')); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); while ($row = array_shift($rows)) { + if (!hasAliasObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $row['address'])) { + continue; + } // Keep old array for backward compatibility $data['fixed_sender_aliases'][] = $row['address']; // Split into allowed/blocked for proper display @@ -4964,6 +4979,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { else if ($SaslLogs['service'] == 'SSO') { $last_sso_login = strtotime($SaslLogs['datetime']); } + else if ($SaslLogs['service'] == 'SOGO') { + $last_sogo_login = strtotime($SaslLogs['datetime']); + } } if (!isset($last_imap_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) { $last_imap_login = 0; @@ -4977,10 +4995,14 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { if (!isset($last_sso_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) { $last_sso_login = 0; } + if (!isset($last_sogo_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) { + $last_sogo_login = 0; + } $mailboxdata['last_imap_login'] = $last_imap_login; $mailboxdata['last_smtp_login'] = $last_smtp_login; $mailboxdata['last_pop3_login'] = $last_pop3_login; $mailboxdata['last_sso_login'] = $last_sso_login; + $mailboxdata['last_sogo_login'] = $last_sogo_login; if (!isset($_extra) || $_extra != 'reduced') { $rl = ratelimit('get', 'mailbox', $_data); @@ -5018,7 +5040,16 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $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']; + $send_as = $sender_acl_row['send_as']; + if ($_SESSION['mailcow_cc_role'] == 'domainadmin' && $send_as != '*') { + $in_scope = (substr($send_as, 0, 1) == '@') + ? hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr($send_as, 1)) + : hasAliasObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $send_as); + if (!$in_scope) { + continue; + } + } + $mailboxdata['sender_acl'][] = $send_as; } } $stmt = $pdo->prepare("SELECT `tag_name` @@ -5958,6 +5989,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':domain' => $domain, ':tag_name' => $tag, )); + if ($stmt->rowCount() > 0) { + $stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain"); + $stmt->execute(array( + ':domain' => $domain, + )); + } } } @@ -6010,6 +6047,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':username' => $username, ':tag_name' => $tag, )); + if ($stmt->rowCount() > 0) { + $stmt = $pdo->prepare("UPDATE `mailbox` SET `modified` = NOW() WHERE `username` = :username"); + $stmt->execute(array( + ':username' => $username, + )); + } } } diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js index 348cd1ee8..37abc09c5 100644 --- a/data/web/js/site/mailbox.js +++ b/data/web/js/site/mailbox.js @@ -616,7 +616,7 @@ jQuery(function($){ defaultContent: '' }, { - title: 'RL', + title: lang_edit.ratelimit, data: 'rl', searchable: false, orderable: false, @@ -717,10 +717,6 @@ jQuery(function($){ $.each(json, function (i, item) { item.chkbox = ''; - item.attributes.def_quota_for_mbox = humanFileSize(item.attributes.def_quota_for_mbox); - item.attributes.max_quota_for_mbox = humanFileSize(item.attributes.max_quota_for_mbox); - item.attributes.max_quota_for_domain = humanFileSize(item.attributes.max_quota_for_domain); - item.template = escapeHtml(item.template); if (item.attributes.rl_frame === "s"){ item.attributes.rl_frame = lang_rl.second; @@ -803,16 +799,25 @@ jQuery(function($){ title: lang.mailbox_defquota, data: 'attributes.def_quota_for_mbox', defaultContent: '', + render: function (data, type) { + return type === 'sort' || type === 'type' ? data : humanFileSize(data); + } }, { title: lang.max_quota, data: 'attributes.max_quota_for_mbox', defaultContent: '', + render: function (data, type) { + return type === 'sort' || type === 'type' ? data : humanFileSize(data); + } }, { title: lang.domain_quota_total, data: 'attributes.max_quota_for_domain', defaultContent: '', + render: function (data, type) { + return type === 'sort' || type === 'type' ? data : humanFileSize(data); + } }, { title: lang.gal, @@ -934,6 +939,7 @@ jQuery(function($){ item.last_mail_login = (item.attributes.imap_access == 1 ? '
IMAP @ ' + unix_time_format(Number(item.last_imap_login)) + '

' : '') + (item.attributes.pop3_access == 1 ? '
POP3 @ ' + unix_time_format(Number(item.last_pop3_login)) + '

' : '') + (item.attributes.smtp_access == 1 ? '
SMTP @ ' + unix_time_format(Number(item.last_smtp_login)) + '

' : '') + + '
SOGO @ ' + unix_time_format(Number(item.last_sogo_login)) + '

' + '
SSO @ ' + unix_time_format(Number(item.last_sso_login)) + '
'; /* if (!item.rl) { @@ -1258,8 +1264,6 @@ jQuery(function($){ item.attributes.rl_value = (!item.attributes.rl_value) ? "∞" : escapeHtml(item.attributes.rl_value); item.attributes.ratelimit = item.attributes.rl_value + " " + item.attributes.rl_frame; - item.attributes.quota = humanFileSize(item.attributes.quota); - item.attributes.tls_enforce_in = '' + (item.attributes.tls_enforce_in == 1 ? '1' : '0') + ''; item.attributes.tls_enforce_out = '' + (item.attributes.tls_enforce_out == 1 ? '1' : '0') + ''; item.attributes.pop3_access = '' + (item.attributes.pop3_access == 1 ? '1' : '0') + ''; @@ -1346,6 +1350,9 @@ jQuery(function($){ title: lang.domain_quota, data: 'attributes.quota', defaultContent: '', + render: function (data, type) { + return type === 'sort' || type === 'type' ? data : humanFileSize(data); + } }, { title: lang.tls_enforce_in, diff --git a/data/web/json_api.php b/data/web/json_api.php index 1c280673a..d277efb8a 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -1194,10 +1194,14 @@ if (isset($_GET['query'])) { $data['mailboxes'][$mailbox][] = $mailbox; $user_alias_details = user_get_alias_details($mailbox); foreach ($user_alias_details['direct_aliases'] as $k => $v) { - $data['mailboxes'][$mailbox][] = $k; + if (hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr(strrchr($k, '@'), 1))) { + $data['mailboxes'][$mailbox][] = $k; + } } foreach ($user_alias_details['shared_aliases'] as $k => $v) { - $data['mailboxes'][$mailbox][] = $k; + if (hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr(strrchr($k, '@'), 1))) { + $data['mailboxes'][$mailbox][] = $k; + } } } } diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index f8433cf5b..1e32b60be 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -1642,7 +1642,7 @@ "mailbox_defaults_info": "Define default settings for new mailboxes.", "mailbox_defquota": "Default mailbox size", "mailbox_templates": "Mailbox Templates", - "mailbox_quota": "Max. size of a mailbox", + "mailbox_quota": "Max. mailbox size", "mailboxes": "Mailboxes", "max_aliases": "Max. aliases", "max_mailboxes": "Max. possible mailboxes", @@ -2018,6 +2018,7 @@ "u2f_deprecated": "It seems that your Key was registered using the deprecated U2F method. We will deactivate Two-Factor-Authenticaiton for you and delete your Key.", "u2f_deprecated_important": "Please register your Key in the admin panel with the new WebAuthn method.", "webauthn": "WebAuthn authentication", + "webauthn_registration_failed": "WebAuthn registration failed. The credential could not be verified, please try again.", "waiting_usb_auth": "Waiting for USB device...

Please tap the button on your USB device now.", "waiting_usb_register": "Waiting for USB device...

Please enter your password above and confirm your registration by tapping the button on your USB device.", "yubi_otp": "Yubico OTP authentication" diff --git a/data/web/lang/lang.es-es.json b/data/web/lang/lang.es-es.json index cf68eace9..bc4fed946 100644 --- a/data/web/lang/lang.es-es.json +++ b/data/web/lang/lang.es-es.json @@ -1074,7 +1074,6 @@ "verified_webauthn_login": "Inicio de sesión WebAuthn verificado", "verified_yotp_login": "Inicio de sesión Yubico OTP verificado", "admin_added": "Se agregó el administrador %s", - "admin_api_modified": "Se guardaron los cambios en la API", "admin_removed": "Se eliminó el administrador %s", "app_links": "Se guardaron los cambios en los enlaces de aplicaciones", "app_passwd_added": "Se agregó una nueva contraseña de aplicación", @@ -1333,10 +1332,6 @@ "sogo_profile_reset_help": "Esto destruirá el perfil de SOGo del usuario y eliminará de forma irrecuperable todos sus contactos y datos de calendario.", "spam_score_reset": "Restablecer al valor predeterminado del servidor", "spamfilter_table_domain_policy": "n/d (política del dominio)", - "syncjob_check_log": "Revisar registro", - "syncjob_EX_OK": "Correcto", - "syncjob_EXIT_OVERQUOTA": "El buzón de destino superó su cuota", - "syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Usuario o contraseña incorrectos", "tfa_info": "La autenticación de dos factores ayuda a proteger su cuenta. Si la activa, necesitará contraseñas de aplicación para iniciar sesión en aplicaciones o servicios que no admiten la autenticación de dos factores (p. ej., clientes de correo)." }, "warning": { diff --git a/data/web/lang/lang.it-it.json b/data/web/lang/lang.it-it.json index 0c9288555..3cbb4a269 100644 --- a/data/web/lang/lang.it-it.json +++ b/data/web/lang/lang.it-it.json @@ -95,7 +95,6 @@ "bcc_dest_format": "Il destinatario in copia nascosta deve essere un singolo indirizzo email.
Se si vuole spedire una copia del messaggio a più destinatari, bisogna creare un alias ed utilizzarlo per questa opzione.", "app_passwd_protocols": "Protocolli consentiti per la password dell'app", "tags": "Tag", - "dry": "Simula sincronizzazione", "internal": "Interno", "internal_info": "Gli alias interni sono accessibili solo dal proprio dominio o dai domini alias.", "sender_allowed": "Consenti l'invio con questo alias" diff --git a/data/web/templates/base.twig b/data/web/templates/base.twig index 0aab62bc9..1acd627e5 100644 --- a/data/web/templates/base.twig +++ b/data/web/templates/base.twig @@ -418,18 +418,24 @@ function recursiveBase64StrToArrayBuffer(obj) { return navigator.credentials.create(json); }).then(function(cred) { return { - id: cred.id, - rawId: arrayBufferToBase64(cred.rawId), - response: { - attestationObject: arrayBufferToBase64(cred.response.attestationObject), - clientDataJSON: arrayBufferToBase64(cred.response.clientDataJSON) - }, - type: cred.type + clientDataJSON: arrayBufferToBase64(cred.response.clientDataJSON), + attestationObject: arrayBufferToBase64(cred.response.attestationObject), + key_id: keyId, + tfa_method: 'webauthn', + confirm_password: '' }; - }).then(function(credData) { - $('#setup_webauthn_register_data').val(JSON.stringify(credData)); - $('#setup_webauthn_reg_form input[name=set_tfa]').val('1'); - $('#setup_webauthn_reg_form').submit(); + }).then(JSON.stringify).then(function(credentialData) { + // send the attestation to the JSON API, like the regular WebAuthn modal does, + // so that the credential is parsed and stored correctly + return window.fetch('/api/v1/add/webauthn-tfa-registration', {method: 'POST', body: credentialData, cache: 'no-cache'}); + }).then(function(response) { + return response.json(); + }).then(function(json) { + if (json.success) { + window.location = window.location.href.split('#')[0]; + } else { + throw new Error(json.msg); + } }).catch(function(err) { $('#setup_webauthn_return_code').show().text(err.message || 'Registration failed'); }); diff --git a/data/web/templates/edit/mailbox.twig b/data/web/templates/edit/mailbox.twig index 0b6325172..66bf8666f 100644 --- a/data/web/templates/edit/mailbox.twig +++ b/data/web/templates/edit/mailbox.twig @@ -97,9 +97,11 @@