Merge pull request #7589 from mailcow/fix/rework-bugs

Fixes for the reworks: imapsync, logins/netfilter, groups, startup, hardening
This commit is contained in:
FreddleSpl0it authored and GitHub committed 2026-11-04 16:32:46 +01:00
commit adb1c74ce3
55 files changed
+874 -281

No files matched your search

+6
View File
@@ -60,6 +60,7 @@ adapt_new_options() {
"ACME_DNS_CHALLENGE" "ACME_DNS_CHALLENGE"
"ACME_DNS_PROVIDER" "ACME_DNS_PROVIDER"
"ACME_ACCOUNT_EMAIL" "ACME_ACCOUNT_EMAIL"
"NGINX_MAX_BODY_SIZE"
) )
sed -i --follow-symlinks '$a\' mailcow.conf sed -i --follow-symlinks '$a\' mailcow.conf
@@ -309,6 +310,11 @@ adapt_new_options() {
echo '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf echo '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf
echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> mailcow.conf echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> mailcow.conf
;; ;;
NGINX_MAX_BODY_SIZE)
echo '# Maximum request size for SOGo webmail, CalDAV/CardDAV and ActiveSync, e.g. 128m or 1g - 0 means unlimited' >> mailcow.conf
echo '# Keep it above the message size limit of Postfix (100 MB by default). The mailcow UI and API stay limited to 64m.' >> mailcow.conf
echo 'NGINX_MAX_BODY_SIZE=128m' >> mailcow.conf
;;
*) *)
echo "${option}=" >> mailcow.conf echo "${option}=" >> mailcow.conf
;; ;;
@@ -318,7 +318,7 @@ class DockerApi:
if 'username' in request_json and 'script_name' in request_json: if 'username' in request_json and 'script_name' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters): for container in self.sync_docker_client.containers.list(filters=filters):
cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' '" + request_json['script_name'].replace("'", "'\\''") + "'"] cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' -- '" + request_json['script_name'].replace("'", "'\\''") + "'"]
sieve_return = container.exec_run(cmd) sieve_return = container.exec_run(cmd)
return self.exec_run_handler('utf8_text_only', sieve_return) return self.exec_run_handler('utf8_text_only', sieve_return)
# api call: container_post - post_action: exec - cmd: maildir - task: cleanup # api call: container_post - post_action: exec - cmd: maildir - task: cleanup
@@ -491,7 +491,7 @@ class DockerApi:
if mailbox in mailbox_seen: if mailbox in mailbox_seen:
continue continue
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' '{mailbox}'"]) acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' -- '{mailbox}'"])
acls = acls.output.decode('utf-8').strip().splitlines() acls = acls.output.decode('utf-8').strip().splitlines()
if len(acls) >= 2: if len(acls) >= 2:
for acl in acls[1:]: for acl in acls[1:]:
@@ -510,7 +510,7 @@ class DockerApi:
if mailbox in mailbox_seen: if mailbox in mailbox_seen:
continue continue
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' '{mailbox}'"]) acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' -- '{mailbox}'"])
acls = acls.output.decode('utf-8').strip().splitlines() acls = acls.output.decode('utf-8').strip().splitlines()
if len(acls) >= 2: if len(acls) >= 2:
for acl in acls[1:]: for acl in acls[1:]:
@@ -534,7 +534,7 @@ class DockerApi:
id = request_json['id'].replace("'", "'\\''") id = request_json['id'].replace("'", "'\\''")
if user and mailbox and id: if user and mailbox and id:
acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' '{self.acl_identifier(id)}'"]) acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' -- '{mailbox}' '{self.acl_identifier(id)}'"])
return self.exec_run_handler('generic', acl_delete_return) return self.exec_run_handler('generic', acl_delete_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl # api call: container_post - post_action: exec - cmd: doveadm - task: set_acl
def container_post__exec__doveadm__set_acl(self, request_json, **kwargs): def container_post__exec__doveadm__set_acl(self, request_json, **kwargs):
@@ -568,7 +568,7 @@ class DockerApi:
rights += right + " " rights += right + " "
if user and mailbox and id and rights: if user and mailbox and id and rights:
acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' '{self.acl_identifier(id)}' {rights}"]) acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' -- '{mailbox}' '{self.acl_identifier(id)}' {rights}"])
return self.exec_run_handler('generic', acl_set_return) return self.exec_run_handler('generic', acl_set_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl_all # api call: container_post - post_action: exec - cmd: doveadm - task: set_acl_all
# Sets the rights of an identifier on every folder of a mailbox, no rights = delete; values are passed as # Sets the rights of an identifier on every folder of a mailbox, no rights = delete; values are passed as
@@ -585,7 +585,7 @@ class DockerApi:
rights = [right.lower() for right in request_json.get('rights', []) if right.lower() in self.acl_rights] rights = [right.lower() for right in request_json.get('rights', []) if right.lower() in self.acl_rights]
if not user or id in ('user=', 'group='): 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") 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"' 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 ' 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"') 'case "$folder" in Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"')
acl_return = container.exec_run(["/bin/bash", "-c", script], acl_return = container.exec_run(["/bin/bash", "-c", script],
@@ -601,7 +601,7 @@ class DockerApi:
for container in self.sync_docker_client.containers.list(filters=filters): for container in self.sync_docker_client.containers.list(filters=filters):
users = [str(user) for user in request_json.get('users', []) if str(user)] users = [str(user) for user in request_json.get('users', []) if str(user)]
flush_return = container.exec_run(["doveadm", "auth", "cache", "flush"] + users) flush_return = container.exec_run(["doveadm", "auth", "cache", "flush", "--"] + users)
return self.exec_run_handler('generic', flush_return) return self.exec_run_handler('generic', flush_return)
# Rights doveadm acl set accepts from the API # Rights doveadm acl set accepts from the API
+11 -4
View File
@@ -306,13 +306,20 @@ IMAPSYNC_TABLE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DB
echo "$(envsubst < /usr/local/bin/maildir_gc.sh)" > /usr/local/bin/maildir_gc.sh echo "$(envsubst < /usr/local/bin/maildir_gc.sh)" > /usr/local/bin/maildir_gc.sh
# Wait for updated schema (DB_VERSION from docker-compose.yml), the userdb query reads tables the init_db of php-fpm creates on updates # Wait for updated schema (DB_VERSION from docker-compose.yml), the userdb query reads tables the init_db of php-fpm creates on updates
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN) # No error while the versions table is missing (fresh install, set -e)
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; do DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null || true)
DBV_WAIT=0
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]] && [[ ${DBV_WAIT} -lt 60 ]]; do
echo "Waiting for schema update..." echo "Waiting for schema update..."
sleep 5 sleep 5
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN) DBV_WAIT=$((DBV_WAIT + 1))
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null || true)
done done
echo "DB schema is ${DBV_NOW}" if [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; then
echo -e "\e[31mDB schema is ${DBV_NOW}, expected ${DB_VERSION} - starting anyway, check the php-fpm log (init_db)\e[0m"
else
echo "DB schema is ${DBV_NOW}"
fi
# GUID generation # GUID generation
while [[ ${VERSIONS_OK} != 'OK' ]]; do while [[ ${VERSIONS_OK} != 'OK' ]]; do
+54 -4
View File
@@ -1,10 +1,12 @@
#!/usr/bin/python3 #!/usr/bin/python3
import fcntl import fcntl
import ipaddress
import json import json
import os import os
import re import re
import signal import signal
import socket
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -18,6 +20,15 @@ max_output = 16 * 1024 * 1024
lock_file = run_dir + '/imapsync_busy.lock' lock_file = run_dir + '/imapsync_busy.lock'
# An OAuth run needs a token valid this long at start (half the lifetime for short-lived tokens) # An OAuth run needs a token valid this long at start (half the lifetime for short-lived tokens)
token_min_valid = 900 token_min_valid = 900
# Custom params never passed, also when stored before (same list as CustomParams::BLOCKED in the web UI)
blocked_params = {'abort', 'delete2foldersbutnot', 'delete2foldersonly', 'logdir', 'logfile', 'maxlinelengthcmd',
'oauthrefreshcmd1', 'oauthrefreshcmd2', 'passfile1', 'passfile2', 'pidfile', 'pipemess', 'regexflag', 'regexmess',
'regextrans2', 'showpasswords', 'skipmess', 'tests', 'testsdebug', 'testslive', 'testslive6', 'testsunit', 'tmpdir'}
# imapsync only gets these variables, not the secrets of the container environment
job_env_keys = ('PATH', 'LANG', 'LC_ALL', 'TZ')
# A source must not point to mailcow itself (internal Docker network), host1 is checked before each run
internal_nets = [ipaddress.ip_network(os.environ.get('IPV4_NETWORK', '172.22.1') + '.0/24', strict=False),
ipaddress.ip_network(os.environ.get('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64'), strict=False)]
# Refreshed tokens are written to the token files of running jobs this often, imapsync re-reads them on reconnect # Refreshed tokens are written to the token files of running jobs this often, imapsync re-reads them on reconnect
token_sync_interval = 60 token_sync_interval = 60
@@ -25,7 +36,7 @@ token_sync_interval = 60
next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage, next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage,
i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params, i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params,
i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id, i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id,
s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow s.host1, s.port1, s.enc1, s.verify_cert, s.auth_type, s.oauth_flow
FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id
WHERE i.active = 1 WHERE i.active = 1
AND s.active = 1 AND s.active = 1
@@ -106,6 +117,28 @@ def defer_job(job_id, text, exit_status):
(text, exit_status, job_id)) (text, exit_status, job_id))
deferred.add(job_id) deferred.add(job_id)
# True when host resolves to loopback, link-local or the internal network; unknown hosts are left to imapsync
def internal_host(host):
try:
addrs = {a[4][0] for a in socket.getaddrinfo(host, None)}
except (socket.gaierror, UnicodeError):
return False
for addr in addrs:
ip = ipaddress.ip_address(addr.split('%')[0])
if ip.version == 6 and ip.ipv4_mapped:
ip = ip.ipv4_mapped
if ip.is_loopback or ip.is_unspecified or ip.is_link_local or any(ip in net for net in internal_nets):
return True
return False
# SNI takes host names only
def ip_literal(host):
try:
ipaddress.ip_address(host)
return True
except ValueError:
return False
def oauth_token(j): def oauth_token(j):
if j['oauth_flow'] == 'authorization_code': if j['oauth_flow'] == 'authorization_code':
return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {} return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {}
@@ -150,6 +183,11 @@ def prepare_job(j, global_bw, files):
f.write(content) f.write(content)
return path return path
if internal_host(j['host1']):
finish_job(j['id'], 'Host1 %s is an address of mailcow itself, the sync job does not connect to it\n' % j['host1'], 0,
'EXIT_CONNECTION_FAILURE_HOST1')
return None
auth_type = j['auth_type'] or 'PLAIN' auth_type = j['auth_type'] or 'PLAIN'
oauth = None oauth = None
if auth_type == 'XOAUTH2': if auth_type == 'XOAUTH2':
@@ -174,7 +212,11 @@ def prepare_job(j, global_bw, files):
except ValueError: except ValueError:
pairs = [] pairs = []
for p in pairs if isinstance(pairs, list) else []: for p in pairs if isinstance(pairs, list) else []:
if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']): if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']) \
or p['o'] in blocked_params:
continue
# The certificate check protects the OAuth token of the source, a job must not turn it off
if oauth and p['o'] == 'sslargs1':
continue continue
value = '' if p.get('v') is None else str(p['v']) value = '' if p.get('v') is None else str(p['v'])
custom_params.append('--' + p['o'] if value == '' else '--' + p['o'] + '=' + value) custom_params.append('--' + p['o'] if value == '' else '--' + p['o'] + '=' + value)
@@ -205,7 +247,13 @@ def prepare_job(j, global_bw, files):
cmd.append(opt) cmd.append(opt)
enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1']) enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1'])
if enc1: if enc1:
cmd.append(enc1) # Mail::IMAPClient starts TLS without a host name: check the certificate against host1, not its address
cmd += [enc1, '--sslargs1', 'SSL_verifycn_scheme=imap', '--sslargs1', 'SSL_verifycn_name=' + j['host1']]
if not ip_literal(j['host1']):
cmd += ['--sslargs1', 'SSL_hostname=' + j['host1']]
# imapsync does not check certificates by default; custom sslargs1 of password jobs can still change it
if j['verify_cert'] == 1:
cmd += ['--sslargs1', 'SSL_verify_mode=1']
cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])] cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])]
cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')] cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')]
if j['dry'] == 1: if j['dry'] == 1:
@@ -216,8 +264,10 @@ def prepare_job(j, global_bw, files):
# Worker: imapsync with output to a file, no inherited fds (DB socket, lock) # Worker: imapsync with output to a file, no inherited fds (DB socket, lock)
def start_job(job): def start_job(job):
env = {k: os.environ[k] for k in job_env_keys if k in os.environ}
env['HOME'] = run_dir
with open(job['outfile'], 'wb') as out: with open(job['outfile'], 'wb') as out:
return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT) return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT, env=env)
# Store the result of a finished worker and remove its temp files # Store the result of a finished worker and remove its temp files
def reap_job(job): def reap_job(job):
@@ -7,9 +7,6 @@ class BanManager:
and the allowlist. Every firewall change runs under lock, which also guards the other firewall work of netfilter and the allowlist. Every firewall change runs under lock, which also guards the other firewall work of netfilter
(chains, SNAT), because the backends must not run in parallel.""" (chains, SNAT), because the backends must not run in parallel."""
# Seconds failed logins of an address are not counted after the auth backend was unavailable for it
EXCUSE_TIME = 60
def __init__(self, r, tables, options, logger, lock, clock=time.time): def __init__(self, r, tables, options, logger, lock, clock=time.time):
self.r = r self.r = r
self.tables = tables self.tables = tables
@@ -21,16 +18,6 @@ class BanManager:
self.banned = set() self.banned = set()
self.denied = {} self.denied = {}
self.allowlist = [] self.allowlist = []
self.excused = {}
def excuse(self, ip):
"""The auth backend could not check a login of ip: its failed logins are not counted for EXCUSE_TIME"""
with self.lock:
now = self.clock()
if self.excused.get(ip, 0) <= now:
self.logger.logWarn('Auth backend unavailable for %s, its failed logins of the next %d seconds are not counted' % (
ip, self.EXCUSE_TIME))
self.excused[ip] = now + self.EXCUSE_TIME
def attempt(self, ip): def attempt(self, ip):
"""Counts a failed attempt of ip and bans its network after max_attempts within retry_window""" """Counts a failed attempt of ip and bans its network after max_attempts within retry_window"""
@@ -43,9 +30,6 @@ class BanManager:
if allowed is not None: if allowed is not None:
self.logger.logInfo('Address %s is allowlisted by rule %s' % (ip, allowed)) self.logger.logInfo('Address %s is allowlisted by rule %s' % (ip, allowed))
return return
if self.excused.get(ip, 0) > now:
self.logger.logInfo('Not counting the failed login of %s, the auth backend was unavailable' % ip)
return
if net in self.banned: if net in self.banned:
# log lines of connections from before the ban # log lines of connections from before the ban
return return
@@ -208,8 +192,6 @@ class BanManager:
for net in [net for net, counter in self.counters.items() for net in [net for net, counter in self.counters.items()
if net not in self.banned and counter['ban_counter'] == 0 and now - counter['last_attempt'] > window]: if net not in self.banned and counter['ban_counter'] == 0 and now - counter['last_attempt'] > window]:
del self.counters[net] del self.counters[net]
for ip in [ip for ip, until in self.excused.items() if until <= now]:
del self.excused[ip]
def apply(self, nets): def apply(self, nets):
for net in self.tables.ban(nets): for net in self.tables.ban(nets):
@@ -109,12 +109,6 @@ class Netfilter:
def handle_line(self, line): def handle_line(self, line):
# a broken line must not end the watcher # a broken line must not end the watcher
try: try:
excused = Regexes.temporary_failure(line)
if excused is not None:
ip = Regexes.parse_ip(excused)
if self.external(ip):
self.bans.excuse(ip)
return
result = self.regexes.match(line) result = self.regexes.match(line)
if result is None: if result is None:
return return
+69 -16
View File
@@ -6,20 +6,43 @@ import re
class Regexes: class Regexes:
"""Rules for log lines (Redis F2B_REGEX): rule id => regex whose first group captures the client address""" """Rules for log lines (Redis F2B_REGEX): rule id => regex whose first group captures the client address"""
# Anchored to the line start: text clients control in other lines (login names, SMTP commands) must not match.
# Failed IMAP, POP3, Sieve and SMTP logins are reported by mailcowauth.php (rule 3), not by Dovecot or Postfix.
# Only the master passdb is checked by Dovecot itself (rule 8); the last address is the one Dovecot added.
DEFAULTS = { DEFAULTS = {
1: r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)', 1: r'^mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)$',
2: r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)', 2: r'^Rspamd UI: Invalid password by ([0-9a-f\.:]+)$',
3: r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+', 3: r'^MAILCOWAUTH: Login failed for user .+ with service \w* from IP ([0-9a-f\.:]+)$',
4: r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+', 4: r'^warning: non-SMTP command from [^\[\]\s]*\[([0-9a-f\.:]+)\]:.+',
5: r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+', 5: r'^NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
6: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)', 6: r'^(?:\[\d+\]: )?SOGo\w* Login from \'(?:[0-9a-f\.:]+, )*([0-9a-f\.:]+)\' for user .+ might not have worked',
7: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)', 7: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
8: r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked', 8: r'^auth: passwd-file\(.*,([0-9a-f\.:]+),master(?:,<[^<>]*>)?\): (?:Password mismatch|unknown user)',
9: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
} }
# Dovecot could not ask the auth backend (identity provider down, mailcowauth.php unreachable). Postfix logs # Former default rules (with any rule id) => rule id of their replacement, None: the rule is removed.
# such a temporary failure like a wrong password, so failed logins of the address are not counted for a while. # Rules the admin changed are kept.
TEMPORARY_FAILURE = re.compile(r'lua\([^,]+,([0-9a-f\.:]+)(?:,<[^>]+>)?\): passdb-lua: Upstream (?:unavailable|unreachable)') FORMER_DEFAULTS = {
r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)': 1,
r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)': 2,
r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed': 3,
r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+': 3,
r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+': 4,
r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+': 5,
r"SOGo.+ Login from '([0-9a-f\.:]+)' for user .+ might not have worked": 6,
r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked': 6,
r'-login: Disconnected \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),': None,
r'-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),': None,
r'-login: Aborted login \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'-login: Aborted login \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'auth: static\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8,
r'auth: \w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8,
r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8,
r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)': None,
}
# Lines of the web UI are not sanitized like syslog lines: a newline in a login name must not hide the address
CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]')
def __init__(self, logger): def __init__(self, logger):
self.logger = logger self.logger = logger
@@ -42,6 +65,10 @@ class Regexes:
if not isinstance(stored, dict): if not isinstance(stored, dict):
self.logger.logWarn('F2B_REGEX is no JSON object, keeping the current rules') self.logger.logWarn('F2B_REGEX is no JSON object, keeping the current rules')
return return
migrated = self.migrate(stored)
if migrated != stored:
self.store_migrated(r)
stored = migrated
rules = [] rules = []
for rule_id, regex in sorted(stored.items(), key=lambda item: self.sort_key(item[0])): for rule_id, regex in sorted(stored.items(), key=lambda item: self.sort_key(item[0])):
@@ -54,6 +81,7 @@ class Regexes:
def match(self, line): def match(self, line):
"""(rule id, captured address) of the first rule that matches the line, None if no rule matches""" """(rule id, captured address) of the first rule that matches the line, None if no rule matches"""
line = self.CONTROL_CHARS.sub('_', line)
for rule_id, regex in self.rules: for rule_id, regex in self.rules:
result = regex.search(line) result = regex.search(line)
if result and result.lastindex and result.group(1): if result and result.lastindex and result.group(1):
@@ -61,10 +89,35 @@ class Regexes:
return None return None
@classmethod @classmethod
def temporary_failure(cls, line): def migrate(cls, stored):
"""Address of a login that failed because the auth backend was unavailable, None for other lines""" """The stored rules with former default rules replaced by the current ones (or removed)"""
result = cls.TEMPORARY_FAILURE.search(line) migrated = {}
return result.group(1) if result else None for rule_id, regex in stored.items():
if isinstance(regex, str) and regex in cls.FORMER_DEFAULTS:
replacement = cls.FORMER_DEFAULTS[regex]
if replacement is None:
continue
regex = cls.DEFAULTS[replacement]
migrated[rule_id] = regex
return migrated
def store_migrated(self, r):
# WATCH keeps a save of the UI between reading and writing
def replace(pipe):
current = json.loads(pipe.get('F2B_REGEX') or '{}')
if not isinstance(current, dict):
return
migrated = self.migrate(current)
if migrated == current:
return
pipe.multi()
pipe.set('F2B_REGEX', json.dumps(migrated, ensure_ascii=False))
try:
r.transaction(replace, 'F2B_REGEX')
self.logger.logInfo('Replaced former default rules in F2B_REGEX')
except Exception as ex:
self.logger.logWarn('Could not replace former default rules in F2B_REGEX: %s' % ex)
@staticmethod @staticmethod
def parse_ip(text): def parse_ip(text):
+7
View File
@@ -1,4 +1,5 @@
import os import os
import re
import subprocess import subprocess
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
@@ -44,6 +45,11 @@ def prepare_template_vars():
ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1") ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1")
additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "") additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "")
trusted_proxies = os.getenv("TRUSTED_PROXIES", "") trusted_proxies = os.getenv("TRUSTED_PROXIES", "")
# nginx size syntax, invalid values fall back to the default
max_body_size = os.getenv("NGINX_MAX_BODY_SIZE", "128m").strip()
if not re.fullmatch(r'[0-9]+[kKmMgG]?', max_body_size):
print(f"Invalid NGINX_MAX_BODY_SIZE '{max_body_size}', using 128m")
max_body_size = "128m"
template_vars = { template_vars = {
'IPV4_NETWORK': ipv4_network, 'IPV4_NETWORK': ipv4_network,
@@ -60,6 +66,7 @@ def prepare_template_vars():
'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"), 'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"),
'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false", 'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false",
'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"), 'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"),
'NGINX_MAX_BODY_SIZE': max_body_size,
} }
ssl_dir = '/etc/ssl/mail/' ssl_dir = '/etc/ssl/mail/'
+13
View File
@@ -43,6 +43,19 @@ while ! mariadb-admin status --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u$
sleep 2 sleep 2
done done
# Wait for the schema update (DB_VERSION) at startup, do not restart them meanwhile.
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
DBV_WAIT=0
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]] && [[ ${DBV_WAIT} -lt 60 ]]; do
echo "Waiting for schema update..."
sleep 5
DBV_WAIT=$((DBV_WAIT + 1))
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
done
if [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; then
echo -e "\e[31m$(date) - DB schema is ${DBV_NOW}, expected ${DB_VERSION} - starting checks anyway, check the php-fpm log (init_db)\e[0m"
fi
# Do not attempt to write to slave # Do not attempt to write to slave
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning" REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
+24 -9
View File
@@ -68,13 +68,17 @@ require_once 'functions.acl.inc.php';
$isSOGoRequest = $post['real_rip'] == getenv('IPV4_NETWORK') . '.248'; $isSOGoRequest = $post['real_rip'] == getenv('IPV4_NETWORK') . '.248';
// real_rip is the address that connected to Dovecot (for proxied connections the proxy), client_ip the client (for logs)
$client_ip = filter_var($post['client_ip'] ?? '', FILTER_VALIDATE_IP) ? $post['client_ip'] : $post['real_rip'];
$result = false; $result = false;
// set when the password is right, but the account does not allow this login
$denied = false;
if ($isSOGoRequest) { if ($isSOGoRequest) {
// This is a SOGo Auth request. First check for SSO password. // This is a SOGo Auth request. First check for SSO password.
$sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass"); $sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass");
if ($sogo_sso_pass === $post['password']){ if ($sogo_sso_pass === $post['password']){
error_log('MAILCOWAUTH: SOGo SSO auth for user ' . $post['username']); error_log('MAILCOWAUTH: SOGo SSO auth for user ' . $post['username']);
set_sasl_log($post['username'], $post['real_rip'], "SOGO"); set_sasl_log($post['username'], $client_ip, "SOGO");
$result = true; $result = true;
} }
} }
@@ -91,10 +95,10 @@ if ($result === false){
'service' => $post['service'], 'service' => $post['service'],
'is_internal' => true, 'is_internal' => true,
'remote_addr' => $post['real_rip'] 'remote_addr' => $post['real_rip']
)); ), $denied);
if ($result) { if ($result) {
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $post['real_rip']); error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip);
set_sasl_log($post['username'], $post['real_rip'], $service); set_sasl_log($post['username'], $client_ip, $service);
} }
} }
if ($result === false){ if ($result === false){
@@ -102,26 +106,37 @@ if ($result === false){
$iam_provider = identity_provider('init'); $iam_provider = identity_provider('init');
$iam_settings = identity_provider('get'); $iam_settings = identity_provider('get');
try { try {
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service'])); $result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']), $denied);
} catch (\Mailcow\Auth\IdpUnavailable $e) { } catch (\Mailcow\Auth\IdpUnavailable $e) {
// temporary failure for Dovecot, no failed login // temporary failure for Dovecot, no failed login
error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip'] . ": " . $e->getMessage()); error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip . ": " . $e->getMessage());
http_response_code(503); // Service Unavailable http_response_code(503); // Service Unavailable
echo json_encode($return); echo json_encode($return);
session_destroy(); session_destroy();
exit; exit;
} }
if ($result) { if ($result) {
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']); error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip);
set_sasl_log($post['username'], $post['real_rip'], $post['service']); set_sasl_log($post['username'], $client_ip, $post['service']);
} }
} }
if ($result) { if ($result) {
http_response_code(200); // OK http_response_code(200); // OK
$return['success'] = true; $return['success'] = true;
} elseif ($denied) {
// Logged, but no failed login for netfilter: e.g. a client of a shared mailbox or without IMAP access
error_log("MAILCOWAUTH: Login denied for user " . $post['username'] . " with service " . $service . " from IP " . $client_ip);
http_response_code(401); // Unauthorized
} else { } else {
error_log("MAILCOWAUTH: Login failed for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']); // Same line for the log and netfilter, which counts it for IMAP, POP3, Sieve and SMTP (Postfix asks Dovecot);
// SOGo logins have their own rule
$failed = "MAILCOWAUTH: Login failed for user " . $post['username'] . " with service " .
preg_replace('/[^a-z0-9]/', '', strtolower((string)$service)) . " from IP " . $client_ip;
error_log($failed);
if (!$isSOGoRequest) {
$redis->publish("F2B_CHANNEL", $failed);
}
http_response_code(401); // Unauthorized http_response_code(401); // Unauthorized
} }
+3 -4
View File
@@ -1,8 +1,4 @@
function auth_password_verify(request, password) function auth_password_verify(request, password)
if request.domain == nil then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "No such user"
end
local json = require "cjson" local json = require "cjson"
local ltn12 = require "ltn12" local ltn12 = require "ltn12"
local https = require "ssl.https" local https = require "ssl.https"
@@ -11,7 +7,10 @@ function auth_password_verify(request, password)
local req = { local req = {
username = request.user, username = request.user,
password = password, password = password,
-- address that connected to Dovecot, for proxied connections the proxy
real_rip = request.real_rip, real_rip = request.real_rip,
-- address of the client, also for proxied connections
client_ip = request.rip,
service = request.service service = request.service
} }
local req_json = json.encode(req) local req_json = json.encode(req)
+2 -8
View File
@@ -51,13 +51,6 @@ recipient_delimiter = +
auth_master_user_separator = * auth_master_user_separator = *
mail_shared_explicit_inbox = yes mail_shared_explicit_inbox = yes
mail_prefetch_count = 30 mail_prefetch_count = 30
passdb {
driver = lua
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
result_success = return-ok
result_failure = continue
result_internalfail = continue
}
# try a master passwd # try a master passwd
passdb { passdb {
driver = passwd-file driver = passwd-file
@@ -67,8 +60,9 @@ passdb {
} }
# check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail # check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail
# a return of the following passdb is mandatory # a return of the following passdb is mandatory
# each check asks mailcowauth.php, which reports a failed login to netfilter once
# cache_key with password: the lua default (%u) would let a temporary failure fall back to the negative entry # cache_key with password: the lua default (%u) would let a temporary failure fall back to the negative entry
# of a passdb lookup (master logins) and log "unknown user", which netfilter counts as failed login # of a passdb lookup (master logins) and reject the login as unknown user
passdb { passdb {
driver = lua driver = lua
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
+7
View File
@@ -47,6 +47,13 @@ http {
default ""; default "";
} }
# Client address for PHP: on the internal sogo-auth listener nginx itself is the client,
# the real address comes from the X-Real-IP header set by /sogo-auth-verify
map $sogo_auth_internal $mailcow_client_ip {
"1" $http_x_real_ip;
default $remote_addr;
}
{% if HTTP_REDIRECT %} {% if HTTP_REDIRECT %}
# HTTP to HTTPS redirect # HTTP to HTTPS redirect
server { server {
@@ -22,7 +22,8 @@ add_header Referrer-Policy strict-origin;
index index.php index.html; index index.php index.html;
client_max_body_size 0; # PHP accepts no larger requests (post_max_size), SOGo webmail, CalDAV/CardDAV and ActiveSync use NGINX_MAX_BODY_SIZE
client_max_body_size 64m;
gzip on; gzip on;
gzip_disable "msie6"; gzip_disable "msie6";
@@ -116,7 +117,8 @@ location ~ \.php$ {
include /etc/nginx/fastcgi_params; include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTP_X_REAL_IP $remote_addr; fastcgi_param REMOTE_ADDR $mailcow_client_ip;
fastcgi_param HTTP_X_REAL_IP $mailcow_client_ip;
# trusted internal-auth marker; empty for external clients (see nginx.conf map) # trusted internal-auth marker; empty for external clients (see nginx.conf map)
fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal; fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
fastcgi_read_timeout 3600; fastcgi_read_timeout 3600;
@@ -181,6 +183,7 @@ location /sogo-auth-verify {
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
proxy_pass http://127.0.0.1:65510/sogo-auth; proxy_pass http://127.0.0.1:65510/sogo-auth;
proxy_pass_request_body off; proxy_pass_request_body off;
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
} }
location ^~ /Microsoft-Server-ActiveSync { location ^~ /Microsoft-Server-ActiveSync {
@@ -204,7 +207,7 @@ location ^~ /Microsoft-Server-ActiveSync {
proxy_busy_buffers_size 512k; proxy_busy_buffers_size 512k;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
client_body_buffer_size 512k; client_body_buffer_size 512k;
client_max_body_size 0; client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
} }
location ^~ /SOGo { location ^~ /SOGo {
@@ -255,7 +258,7 @@ location ^~ /SOGo {
proxy_send_timeout 3600; proxy_send_timeout 3600;
proxy_read_timeout 3600; proxy_read_timeout 3600;
client_body_buffer_size 128k; client_body_buffer_size 128k;
client_max_body_size 0; client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
break; break;
} }
+12 -3
View File
@@ -765,13 +765,22 @@ rspamd_config:register_symbol({
end end
}) })
-- Expand a domain footer: replace only "{= key =}" placeholders with their values.
-- Unknown placeholders render empty, matching the previous engine behaviour.
local function expand_footer(template, replacements)
return (template:gsub('{=%s*([%w_]+)%s*=}', function(key)
local value = replacements[key]
if value == nil then return '' end
return tostring(value)
end))
end
rspamd_config:register_symbol({ rspamd_config:register_symbol({
name = 'MOO_FOOTER', name = 'MOO_FOOTER',
type = 'prefilter', type = 'prefilter',
callback = function(task) callback = function(task)
local cjson = require "cjson" local cjson = require "cjson"
local lua_mime = require "lua_mime" local lua_mime = require "lua_mime"
local lua_util = require "lua_util"
local rspamd_logger = require "rspamd_logger" local rspamd_logger = require "rspamd_logger"
local rspamd_http = require "rspamd_http" local rspamd_http = require "rspamd_http"
local envfrom = task:get_from(1) local envfrom = task:get_from(1)
@@ -844,10 +853,10 @@ rspamd_config:register_symbol({
end end
end end
if footer.html and footer.html ~= "" then if footer.html and footer.html ~= "" then
footer.html = lua_util.jinja_template(footer.html, replacements, true) footer.html = expand_footer(footer.html, replacements)
end end
if footer.plain and footer.plain ~= "" then if footer.plain and footer.plain ~= "" then
footer.plain = lua_util.jinja_template(footer.plain, replacements, true) footer.plain = expand_footer(footer.plain, replacements)
end end
-- add footer -- add footer
+5 -6
View File
@@ -216,8 +216,9 @@ foreach ($rcpts as $rcpt) {
foreach ($rcpt_final_mailboxes as $rcpt_final) { foreach ($rcpt_final_mailboxes as $rcpt_final) {
error_log("QUARANTINE: quarantine pipe: processing quarantine message for rcpt " . $rcpt_final . PHP_EOL); error_log("QUARANTINE: quarantine pipe: processing quarantine message for rcpt " . $rcpt_final . PHP_EOL);
try { try {
$stmt = $pdo->prepare("INSERT INTO `quarantine` (`qid`, `subject`, `score`, `sender`, `rcpt`, `symbols`, `user`, `ip`, `msg`, `action`, `fuzzy_hashes`) // qhash is the secret of the release/delete links in notifications, so it must not be derivable
VALUES (:qid, :subject, :score, :sender, :rcpt, :symbols, :user, :ip, :msg, :action, :fuzzy_hashes)"); $stmt = $pdo->prepare("INSERT INTO `quarantine` (`qid`, `subject`, `score`, `sender`, `rcpt`, `symbols`, `user`, `ip`, `msg`, `action`, `fuzzy_hashes`, `qhash`)
VALUES (:qid, :subject, :score, :sender, :rcpt, :symbols, :user, :ip, :msg, :action, :fuzzy_hashes, :qhash)");
$stmt->execute(array( $stmt->execute(array(
':qid' => $qid, ':qid' => $qid,
':subject' => $subject, ':subject' => $subject,
@@ -229,11 +230,9 @@ foreach ($rcpt_final_mailboxes as $rcpt_final) {
':ip' => $ip, ':ip' => $ip,
':msg' => $raw_data, ':msg' => $raw_data,
':action' => $action, ':action' => $action,
':fuzzy_hashes' => $fuzzy ':fuzzy_hashes' => $fuzzy,
':qhash' => bin2hex(random_bytes(32))
)); ));
$lastId = $pdo->lastInsertId();
$stmt_update = $pdo->prepare("UPDATE `quarantine` SET `qhash` = SHA2(CONCAT(`id`, `qid`), 256) WHERE `id` = :id");
$stmt_update->execute(array(':id' => $lastId));
$stmt = $pdo->prepare('DELETE FROM `quarantine` WHERE `rcpt` = :rcpt AND `id` NOT IN ( $stmt = $pdo->prepare('DELETE FROM `quarantine` WHERE `rcpt` = :rcpt AND `id` NOT IN (
SELECT `id` SELECT `id`
FROM ( FROM (
+18
View File
@@ -1936,6 +1936,14 @@ paths:
- TLS - TLS
- SSL - SSL
- PLAIN - PLAIN
verify_cert:
description: >-
1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check
it (self-signed, expired or not matching the host).
type: integer
enum:
- 0
- 1
auth_type: auth_type:
description: >- description: >-
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
@@ -5080,6 +5088,15 @@ paths:
- TLS - TLS
- SSL - SSL
- PLAIN - PLAIN
verify_cert:
description: >-
1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check
it (self-signed, expired or not matching the host). Turning the check off
needs access to all sync jobs of the source, like changing the server.
type: integer
enum:
- 0
- 1
auth_type: auth_type:
description: >- description: >-
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
@@ -7313,6 +7330,7 @@ paths:
host1: imap.example.org host1: imap.example.org
port1: 993 port1: 993
enc1: SSL enc1: SSL
verify_cert: 1
auth_type: XOAUTH2 auth_type: XOAUTH2
oauth_flow: authorization_code oauth_flow: authorization_code
oauth_token_endpoint: https://login.example.org/token oauth_token_endpoint: https://login.example.org/token
+47 -48
View File
@@ -144,6 +144,38 @@ try {
} }
$username = trim((string)$email); $username = trim((string)$email);
if (!filter_var($username, FILTER_VALIDATE_EMAIL)) {
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => "none",
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: invalid email address in request"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
error_log("Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
}
catch (RedisException $e) {
// Silently fail
}
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>600</ErrorCode>
<Message>Invalid Request</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
try { try {
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox` $stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain` INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
@@ -169,46 +201,8 @@ catch(PDOException $e) {
exit(0); exit(0);
} }
// Mailbox not found or not active - return generic error to prevent user enumeration $email = $username;
if (empty($MailboxData)) { $displayname = $email;
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email,
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: mailbox not found or inactive"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
error_log("Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
}
catch (RedisException $e) {
// Silently fail
}
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>600</ErrorCode>
<Message>Invalid Request</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
if (!empty($MailboxData['name'])) {
$displayname = $MailboxData['name'];
}
else {
$displayname = $email;
}
try { try {
$json = json_encode( $json = json_encode(
array( array(
@@ -216,11 +210,16 @@ try {
"ua" => $_SERVER['HTTP_USER_AGENT'], "ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email, "user" => $email,
"ip" => $_SERVER['REMOTE_ADDR'], "ip" => $_SERVER['REMOTE_ADDR'],
"service" => $autodiscover_config['autodiscoverType'] // The answer is the same, only the log tells unknown or inactive mailboxes apart
"service" => (empty($MailboxData)) ? "Error: mailbox not found or inactive" : $autodiscover_config['autodiscoverType']
) )
); );
$redis->lPush('AUTODISCOVER_LOG', $json); $redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100); $redis->lTrim('AUTODISCOVER_LOG', 0, 100);
if (empty($MailboxData)) {
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
error_log("Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
}
} }
catch (RedisException $e) { catch (RedisException $e) {
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
@@ -243,7 +242,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Server><?=$autodiscover_config['imap']['server'];?></Server> <Server><?=$autodiscover_config['imap']['server'];?></Server>
<Port><?=$autodiscover_config['imap']['port'];?></Port> <Port><?=$autodiscover_config['imap']['port'];?></Port>
<DomainRequired>off</DomainRequired> <DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName> <LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
<SPA>off</SPA> <SPA>off</SPA>
<SSL>on</SSL> <SSL>on</SSL>
<AuthRequired>on</AuthRequired> <AuthRequired>on</AuthRequired>
@@ -253,7 +252,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Server><?=$autodiscover_config['smtp']['server'];?></Server> <Server><?=$autodiscover_config['smtp']['server'];?></Server>
<Port><?=$autodiscover_config['smtp']['port'];?></Port> <Port><?=$autodiscover_config['smtp']['port'];?></Port>
<DomainRequired>off</DomainRequired> <DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName> <LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
<SPA>off</SPA> <SPA>off</SPA>
<SSL>on</SSL> <SSL>on</SSL>
<AuthRequired>on</AuthRequired> <AuthRequired>on</AuthRequired>
@@ -265,15 +264,15 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
?> ?>
<Protocol> <Protocol>
<Type>CalDAV</Type> <Type>CalDAV</Type>
<Server>https://<?=$autodiscover_config['caldav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['caldav']['port']; ?>/SOGo/dav/<?=$email;?>/</Server> <Server>https://<?=$autodiscover_config['caldav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['caldav']['port']; ?>/SOGo/dav/<?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?>/</Server>
<DomainRequired>off</DomainRequired> <DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName> <LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
</Protocol> </Protocol>
<Protocol> <Protocol>
<Type>CardDAV</Type> <Type>CardDAV</Type>
<Server>https://<?=$autodiscover_config['carddav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['carddav']['port']; ?>/SOGo/dav/<?=$email;?>/</Server> <Server>https://<?=$autodiscover_config['carddav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['carddav']['port']; ?>/SOGo/dav/<?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?>/</Server>
<DomainRequired>off</DomainRequired> <DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName> <LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
</Protocol> </Protocol>
<?php <?php
} }
@@ -288,7 +287,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Culture>en:en</Culture> <Culture>en:en</Culture>
<User> <User>
<DisplayName><?=htmlspecialchars($displayname, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></DisplayName> <DisplayName><?=htmlspecialchars($displayname, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></DisplayName>
<EMailAddress><?=$email;?></EMailAddress> <EMailAddress><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></EMailAddress>
</User> </User>
<Action> <Action>
<Settings> <Settings>
+2 -1
View File
@@ -227,7 +227,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
if (isset($_GET['syncjob']) && if (isset($_GET['syncjob']) &&
is_numeric($_GET['syncjob'])) { is_numeric($_GET['syncjob'])) {
$id = $_GET["syncjob"]; $id = $_GET["syncjob"];
$result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id); // Without the syncjobs ACL a job cannot be saved, so do not show the form
$result = (($_SESSION['acl']['syncjobs'] ?? '0') == '1') ? \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id) : [];
$template = 'edit/syncjob.twig'; $template = 'edit/syncjob.twig';
} }
elseif (isset($_GET['syncjob_source']) && elseif (isset($_GET['syncjob_source']) &&
+9
View File
@@ -74,6 +74,15 @@ function admin($_action, $_data = null) {
$usernames = $_data['username']; $usernames = $_data['username'];
} }
foreach ($usernames as $username) { foreach ($usernames as $username) {
// A pending password update only allows changing the own password
if (!empty($_SESSION['pending_pw_update']) && $username != $_SESSION['mailcow_cc_username']) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => 'access_denied'
);
continue;
}
$is_now = admin('details', $username); $is_now = admin('details', $username);
if (!empty($is_now)) { if (!empty($is_now)) {
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active']; $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
@@ -5,6 +5,15 @@ function app_passwd($_action, $_data = null) {
$_data_log = $_data; $_data_log = $_data;
!isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*'; !isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*';
!isset($_data_log['app_passwd2']) ?: $_data_log['app_passwd2'] = '*'; !isset($_data_log['app_passwd2']) ?: $_data_log['app_passwd2'] = '*';
if (in_array($_action, array('add', 'edit', 'delete')) &&
(!isset($_SESSION['acl']['app_passwds']) || $_SESSION['acl']['app_passwds'] != "1")) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => 'access_denied'
);
return false;
}
if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) { if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) {
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) { if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
+55 -37
View File
@@ -6,6 +6,8 @@ function check_login($user, $pass, $extra = null) {
$is_internal = $extra['is_internal']; $is_internal = $extra['is_internal'];
$role = $extra['role']; $role = $extra['role'];
$extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service']; $extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service'];
// set when the password is right, but the account does not allow this login
$denied = false;
// Try validate admin // Try validate admin
if (!isset($role) || $role == "admin") { if (!isset($role) || $role == "admin") {
@@ -26,7 +28,7 @@ function check_login($user, $pass, $extra = null) {
// Try validate app password // Try validate app password
if (!isset($role) || $role == "app") { if (!isset($role) || $role == "app") {
$result = apppass_login($user, $pass, $extra); $result = apppass_login($user, $pass, $extra, $denied);
if ($result !== false) { if ($result !== false) {
$real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']); $real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
set_sasl_log($user, $real_rip, $extra['service'], $pass); set_sasl_log($user, $real_rip, $extra['service'], $pass);
@@ -37,7 +39,7 @@ function check_login($user, $pass, $extra = null) {
// Try validate user // Try validate user
if (!isset($role) || $role == "user") { if (!isset($role) || $role == "user") {
try { try {
$result = user_login($user, $pass, $extra); $result = user_login($user, $pass, $extra, $denied);
} catch (\Mailcow\Auth\IdpUnavailable $e) { } catch (\Mailcow\Auth\IdpUnavailable $e) {
// no failed login, netfilter must not count it // no failed login, netfilter must not count it
error_log("mailcow UI: Identity provider unavailable for " . $user . " by " . $_SERVER['REMOTE_ADDR'] . ": " . $e->getMessage()); error_log("mailcow UI: Identity provider unavailable for " . $user . " by " . $_SERVER['REMOTE_ADDR'] . ": " . $e->getMessage());
@@ -54,19 +56,20 @@ function check_login($user, $pass, $extra = null) {
return $result; return $result;
} }
} }
// skip log and only return false if it's an internal request // skip log and only return false if it's an internal request
if ($is_internal == true) return false; if ($is_internal == true) return false;
// A denied login is logged, but not counted by netfilter (e.g. EAS/DAV clients of accounts without access)
$msg = ($denied ? "mailcow UI: Login denied for " : "mailcow UI: Invalid password for ") . $user . " by " . $_SERVER['REMOTE_ADDR'];
if (!isset($_SESSION['ldelay'])) { if (!isset($_SESSION['ldelay'])) {
$_SESSION['ldelay'] = "0"; $_SESSION['ldelay'] = "0";
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); if (!$denied) $redis->publish("F2B_CHANNEL", $msg);
error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); error_log($msg);
} }
elseif (!isset($_SESSION['mailcow_cc_username'])) { elseif (!isset($_SESSION['mailcow_cc_username'])) {
$_SESSION['ldelay'] = $_SESSION['ldelay']+0.5; $_SESSION['ldelay'] = $_SESSION['ldelay']+0.5;
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); if (!$denied) $redis->publish("F2B_CHANNEL", $msg);
error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); error_log($msg);
} }
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
'type' => 'danger', 'type' => 'danger',
@@ -207,7 +210,7 @@ function domainadmin_login($user, $pass){
return false; return false;
} }
function user_login($user, $pass, $extra = null){ function user_login($user, $pass, $extra = null, &$denied = false){
global $pdo; global $pdo;
global $iam_provider; global $iam_provider;
global $iam_settings; global $iam_settings;
@@ -261,11 +264,14 @@ function user_login($user, $pass, $extra = null){
if ($extra['service'] != 'NONE') { if ($extra['service'] != 'NONE') {
$key = strtolower($extra['service']) . "_access"; $key = strtolower($extra['service']) . "_access";
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') { if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
$denied = true;
return false; return false;
} }
} }
return true; return true;
} }
// the IdP accepted the password, the mailbox is inactive
$denied = true;
} }
clear_session(); clear_session();
return false; return false;
@@ -273,18 +279,16 @@ function user_login($user, $pass, $extra = null){
// check if user has access to service (imap, smtp, pop3, sieve) if service is set // check if user has access to service (imap, smtp, pop3, sieve) if service is set
$row['attributes'] = json_decode($row['attributes'], true); $row['attributes'] = json_decode($row['attributes'], true);
// Shared mailboxes have no login of their own, members open them through group shares
if (($row['attributes']['shared'] ?? '0') == '1') {
return false;
}
if ($extra['service'] != 'NONE') {
$key = strtolower($extra['service']) . "_access"; $key = strtolower($extra['service']) . "_access";
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') { // Shared mailboxes have no login of their own, members open them through group shares.
return false; // Checked before asking an IdP (no template side effects).
if (($row['attributes']['shared'] ?? '0') == '1'
|| ($extra['service'] != 'NONE' && isset($row['attributes'][$key]) && $row['attributes'][$key] != '1')
|| $row['active'] != '1' || $row['d_active'] != '1') {
// Denied, not failed, unless the password is wrong; IdP passwords are not checked for that
if ($row['authsource'] != 'mailcow' || verify_hash($row['password'], $pass) !== false) {
$denied = true;
} }
}
// IdP accounts: refuse inactive mailboxes and domains before asking the IdP (no template side effects)
if ($row['authsource'] != 'mailcow' && ($row['active'] != '1' || $row['d_active'] != '1')) {
return false; return false;
} }
switch ($row['authsource']) { switch ($row['authsource']) {
@@ -303,6 +307,7 @@ function user_login($user, $pass, $extra = null){
$stmt->execute(array(':user' => $user)); $stmt->execute(array(':user' => $user));
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
if (empty($row)) { if (empty($row)) {
$denied = true;
return false; return false;
} }
@@ -361,6 +366,7 @@ function user_login($user, $pass, $extra = null){
$stmt->execute(array(':user' => $user)); $stmt->execute(array(':user' => $user));
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
if (empty($row)) { if (empty($row)) {
$denied = true;
return false; return false;
} }
@@ -403,9 +409,6 @@ function user_login($user, $pass, $extra = null){
return $result; return $result;
break; break;
case 'mailcow': case 'mailcow':
if ($row['active'] != 1 || $row['d_active'] != 1) {
return false;
}
// verify password // verify password
if (verify_hash($row['password'], $pass) !== false) { if (verify_hash($row['password'], $pass) !== false) {
@@ -446,13 +449,15 @@ function user_login($user, $pass, $extra = null){
} }
return "user"; return "user";
} }
// TFA accounts use app passwords in mail clients
$denied = true;
} }
break; break;
} }
return false; return false;
} }
function apppass_login($user, $pass, $extra = null){ function apppass_login($user, $pass, $extra = null, &$denied = false){
global $pdo; global $pdo;
$is_internal = $extra['is_internal']; $is_internal = $extra['is_internal'];
@@ -473,15 +478,12 @@ function apppass_login($user, $pass, $extra = null){
return false; return false;
} }
// fetch app password data // fetch app password data, inactive ones too: their right password is denied, not failed
$stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id` FROM `app_passwd` $stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id`,
`mailbox`.`active` as `mailbox_active`, `domain`.`active` as `domain_active`, `mailbox`.`attributes` as `mailbox_attributes` FROM `app_passwd`
INNER JOIN `mailbox` ON `mailbox`.`username` = `app_passwd`.`mailbox` INNER JOIN `mailbox` ON `mailbox`.`username` = `app_passwd`.`mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain` INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
WHERE `mailbox`.`kind` NOT REGEXP 'location|thing|group' WHERE `mailbox`.`kind` NOT REGEXP 'location|thing|group'
AND `mailbox`.`active` = '1'
AND `domain`.`active` = '1'
AND `app_passwd`.`active` = '1'
AND COALESCE(JSON_UNQUOTE(JSON_VALUE(`mailbox`.`attributes`, '$.shared')), '0') != '1'
AND `app_passwd`.`mailbox` = :user" AND `app_passwd`.`mailbox` = :user"
); );
// fetch password data // fetch password data
@@ -491,17 +493,26 @@ function apppass_login($user, $pass, $extra = null){
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) { foreach ($rows as $row) {
if ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1'){ // verify password
if (verify_hash($row['password'], $pass) === 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[$key] != '1' || (isset($mailbox_attributes[$key]) && $mailbox_attributes[$key] != '1')))) {
// right password, but the app password does not allow this login
$denied = true;
continue; continue;
} }
// verify password
if (verify_hash($row['password'], $pass) !== false) {
$_SESSION['app_passwd_id'] = $row['app_passwd_id']; $_SESSION['app_passwd_id'] = $row['app_passwd_id'];
unset($_SESSION['ldelay']); unset($_SESSION['ldelay']);
return "user"; return "user";
} }
}
return false; return false;
} }
@@ -538,7 +549,8 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
// get the mailcow_password attribute from keycloak user // get the mailcow_password attribute from keycloak user
$url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users"; $url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users";
$queryParams = array('email' => $user, 'exact' => true); // as string, http_build_query() would send exact=1 and Keycloak would search for substrings
$queryParams = array('email' => $user, 'exact' => 'true');
$queryString = http_build_query($queryParams); $queryString = http_build_query($queryParams);
$curl = curl_init(); $curl = curl_init();
curl_setopt($curl, CURLOPT_TIMEOUT, 7); curl_setopt($curl, CURLOPT_TIMEOUT, 7);
@@ -559,7 +571,10 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
if ($code != 200) { if ($code != 200) {
throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code); throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code);
} }
// exact=true can still return several users with that email // exact=true can still return several users with that email, only the exact address counts
if (is_array($users)) {
$users = array_values(array_filter($users, fn($u) => is_array($u) && strcasecmp((string)($u['email'] ?? ''), $user) === 0));
}
if (!is_array($users) || count($users) != 1) { if (!is_array($users) || count($users) != 1) {
return false; return false;
} }
@@ -710,12 +725,15 @@ function ldap_mbox_login($user, $pass, $extra = null){
->select(array_values(array_filter(array_merge([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'distinguishedname', 'dn'], ->select(array_values(array_filter(array_merge([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'distinguishedname', 'dn'],
\Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? ''))))); \Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? '')))));
$user_res = $ldap_query->firstOrFail(); $entries = $ldap_query->get();
} catch (\LdapRecord\Query\ObjectNotFoundException $e) {
return false;
} catch (Exception $e) { } catch (Exception $e) {
throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e); throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e);
} }
// the login name must identify exactly one entry, several matches are ambiguous
if (!is_array($entries) || count($entries) != 1) {
return false;
}
$user_res = reset($entries);
$idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp; $idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp;
if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) { if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) {
return false; return false;
+46
View File
@@ -1857,6 +1857,40 @@ function get_tfa($username = null, $id = null) {
} }
} }
function verify_tfa_login($username, $_data) { function verify_tfa_login($username, $_data) {
global $redis;
global $TFA_MAX_FAILS;
global $TFA_FAIL_WINDOW;
if (empty($username)) {
return false;
}
// Count failed second factors per account, so codes cannot be guessed from many addresses
$fails_key = 'TFA_FAILS_' . $username;
if (intval($redis->get($fails_key)) >= $TFA_MAX_FAILS) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $username, '*'),
'msg' => 'tfa_login_locked'
);
$verified = false;
}
else {
$verified = verify_tfa_token($username, $_data);
}
if ($verified !== true) {
// The window starts with the first failure and is not extended by further attempts
if ($redis->incr($fails_key) == 1) {
$redis->expire($fails_key, $TFA_FAIL_WINDOW);
}
// Same message format as failed passwords, so the default fail2ban rule counts it
error_log("mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']);
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']);
return false;
}
$redis->del($fails_key);
return true;
}
function verify_tfa_token($username, $_data) {
global $pdo; global $pdo;
global $yubi; global $yubi;
global $u2f; global $u2f;
@@ -3013,6 +3047,18 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
return false; return false;
} }
// A refresh keeps the logged-in mailbox: a changed IdP email or another IdP user ends the session
if (strtolower($info['email']) !== strtolower($_SESSION['mailcow_cc_username'] ?? '') ||
!\Mailcow\Auth\AuthModule::fromGlobals()->idp->verifyLink($iam_settings['authsource'], $info['email'], (string)($info['sub'] ?? ''))) {
clear_session();
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $info['email'], 'The IdP user does not match the logged-in mailbox'),
'msg' => 'refresh_login_failed'
);
return false;
}
// The mailbox may have been deleted or deactivated since the login // The mailbox may have been deleted or deactivated since the login
$stmt = $pdo->prepare("SELECT `mailbox`.`authsource` FROM `mailbox` $stmt = $pdo->prepare("SELECT `mailbox`.`authsource` FROM `mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain` INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
+3 -2
View File
@@ -767,8 +767,9 @@ function quarantine($_action, $_data = null) {
else { else {
$domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains')); $domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains'));
foreach ($domains as $domain) { foreach ($domains as $domain) {
$stmt = $pdo->prepare('SELECT `id`, `qid`, `subject`, LOCATE("VIRUS_FOUND", `symbols`) AS `virus_flag`, `score`, `rcpt`, `sender`, `action`, UNIX_TIMESTAMP(`created`) AS `created`, `notified` FROM `quarantine` WHERE `rcpt` REGEXP :domain'); // Compare the domain part exactly, a regex would treat dots in the domain as wildcards
$stmt->execute(array(':domain' => '@' . $domain . '$')); $stmt = $pdo->prepare('SELECT `id`, `qid`, `subject`, LOCATE("VIRUS_FOUND", `symbols`) AS `virus_flag`, `score`, `rcpt`, `sender`, `action`, UNIX_TIMESTAMP(`created`) AS `created`, `notified` FROM `quarantine` WHERE SUBSTRING_INDEX(`rcpt`, "@", -1) = :domain');
$stmt->execute(array(':domain' => $domain));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
while($row = array_shift($rows)) { while($row = array_shift($rows)) {
$q_meta[] = $row; $q_meta[] = $row;
+20 -9
View File
@@ -1098,6 +1098,7 @@ function init_db_schema()
"host1" => "VARCHAR(255) NOT NULL", "host1" => "VARCHAR(255) NOT NULL",
"port1" => "SMALLINT UNSIGNED NOT NULL", "port1" => "SMALLINT UNSIGNED NOT NULL",
"enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'", "enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
"verify_cert" => "TINYINT(1) NOT NULL DEFAULT '1'",
"auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'", "auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'",
"oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'", "oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'",
"oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL", "oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL",
@@ -1679,9 +1680,15 @@ function init_db_schema()
// Converted JSON may not fit into the old VARCHAR(512) // Converted JSON may not fit into the old VARCHAR(512)
$pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT"); $pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT");
$imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []); $imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []);
$source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name"); // A legacy source is found by its connection and its only target domain (none for jobs without domain)
$source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`, `active`) $source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` s WHERE `created_by` = '' AND `host1` = :host AND `port1` = :port
VALUES (:name, '', '', 'domain', :host, :port, :enc, :auth, 1)"); AND `enc1` = :enc AND `auth_type` = :auth
AND (SELECT GROUP_CONCAT(`domain`) FROM `imapsync_source_domain` d WHERE d.`source_id` = s.`id`) <=> NULLIF(:domain, '')
ORDER BY `id` LIMIT 1");
$source_name_sel = $pdo->prepare("SELECT COUNT(*) FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name");
// The old runner never checked certificates, legacy sources keep that
$source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `verify_cert`, `auth_type`, `active`)
VALUES (:name, 'Migrated sync jobs', '', 'domain', :host, :port, :enc, 0, :auth, 1)");
$source_domain_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)"); $source_domain_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)");
$job_upd = $pdo->prepare("UPDATE `imapsync` SET `source_id` = :source_id, `custom_params` = :cp, `custom_params_legacy` = :cp_legacy, $job_upd = $pdo->prepare("UPDATE `imapsync` SET `source_id` = :source_id, `custom_params` = :cp, `custom_params_legacy` = :cp_legacy,
`dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL"); `dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL");
@@ -1741,17 +1748,21 @@ function init_db_schema()
$enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS'; $enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS';
$at = strrchr((string)$row['user2'], '@'); $at = strrchr((string)$row['user2'], '@');
$domain = ($at === false) ? '' : strtolower(substr($at, 1)); $domain = ($at === false) ? '' : strtolower(substr($at, 1));
// One source per (host, port, enc, auth, domain), name always unique and <= 100 chars // One source per (host, port, enc, auth, domain), named "host (domain)" plus " 2", " 3"... when taken (<= 100 chars)
$name = 'legacy-' . mb_substr($host, 0, 60) . '-' . $port . '-' . substr(sha1($host . '|' . $port . '|' . $enc . '|' . $auth . '|' . $domain), 0, 8); $conn = array(':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth);
$source_sel->execute(array(':name' => $name)); $source_sel->execute($conn + array(':domain' => $domain));
if (($source_id = $source_sel->fetchColumn()) === false) { if (($source_id = $source_sel->fetchColumn()) === false) {
$source_ins->execute(array(':name' => $name, ':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth)); $base = mb_substr($host . ($domain !== '' ? ' (' . $domain . ')' : ''), 0, 94);
$source_sel->execute(array(':name' => $name)); for ($i = 1, $name = $base; $i < 1000; $name = $base . ' ' . ++$i) {
if (($source_id = $source_sel->fetchColumn()) === false) continue; $source_name_sel->execute(array(':name' => $name));
if ($source_name_sel->fetchColumn() == 0) break;
} }
$source_ins->execute($conn + array(':name' => $name));
if (($source_id = $pdo->lastInsertId()) == 0) continue;
if ($domain !== '') { if ($domain !== '') {
$source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain)); $source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain));
} }
}
$job_upd->execute(array( $job_upd->execute(array(
':source_id' => $source_id, ':source_id' => $source_id,
':cp' => json_encode($pairs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), ':cp' => json_encode($pairs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
@@ -44,6 +44,8 @@ class SyncPlanner
/** @var array<string, DirectoryUser> $candidates email => user to create, template still open */ /** @var array<string, DirectoryUser> $candidates email => user to create, template still open */
$candidates = []; $candidates = [];
$duplicates = []; $duplicates = [];
/** @var array<string, true> $ambiguous unlinked mailboxes whose email several IdP users have, left as they are */
$ambiguous = [];
foreach ($listing->users as $user) { foreach ($listing->users as $user) {
if (isset($owners[$user->id])) { if (isset($owners[$user->id])) {
$username = $owners[$user->id]; $username = $owners[$user->id];
@@ -55,10 +57,19 @@ class SyncPlanner
$plan->skipped++; $plan->skipped++;
} elseif (isset($state->accounts[$user->email])) { } elseif (isset($state->accounts[$user->email])) {
$account = $state->accounts[$user->email]; $account = $state->accounts[$user->email];
if ($account->externalId !== null || isset($plan->links[$user->email])) { if ($account->externalId !== null) {
self::conflict($plan, $user->email . ' is linked to another IdP user'); self::conflict($plan, $user->email . ' is linked to another IdP user');
continue; continue;
} }
if (isset($plan->links[$user->email]) || isset($ambiguous[$user->email])) {
// Which IdP user owns the mailbox is unclear: link none of them
if (!isset($ambiguous[$user->email])) {
self::conflict($plan, 'Several IdP users have the email ' . $user->email);
}
unset($plan->links[$user->email], $matched[$user->email]);
$ambiguous[$user->email] = true;
continue;
}
$plan->links[$user->email] = $user->id; $plan->links[$user->email] = $user->id;
$matched[$user->email] = $user; $matched[$user->email] = $user;
} elseif (isset($state->mailboxes[$user->email]) || !$settings->importUsers || !$user->enabled } elseif (isset($state->mailboxes[$user->email]) || !$settings->importUsers || !$user->enabled
@@ -85,7 +96,7 @@ class SyncPlanner
if ($groups !== null) { if ($groups !== null) {
$this->planGroups($settings, $groups, $state, $usernames, $matched, $plan); $this->planGroups($settings, $groups, $state, $usernames, $matched, $plan);
} }
$this->planStates($settings, $state, $matched, $plan); $this->planStates($settings, $state, $matched, $ambiguous, $plan);
if ($settings->periodicSync) { if ($settings->periodicSync) {
$this->planUpdates($settings, $state, $matched, $plan); $this->planUpdates($settings, $state, $matched, $plan);
} }
@@ -211,10 +222,13 @@ class SyncPlanner
} }
/** @param array<string, DirectoryUser> $matched */ /** @param array<string, DirectoryUser> $matched */
private function planStates(Settings $settings, State $state, array $matched, SyncPlan $plan): void private function planStates(Settings $settings, State $state, array $matched, array $ambiguous, SyncPlan $plan): void
{ {
foreach ($state->accounts as $username => $account) { foreach ($state->accounts as $username => $account) {
$username = (string)$username; $username = (string)$username;
if (isset($ambiguous[$username])) {
continue;
}
$user = $matched[$username] ?? null; $user = $matched[$username] ?? null;
if ($user === null && $account->missingSince === null) { if ($user === null && $account->missingSince === null) {
$plan->missing[] = $username; $plan->missing[] = $username;
@@ -19,6 +19,14 @@ class CustomParams
{ {
private const MAX_PAIRS = 100; private const MAX_PAIRS = 100;
private const MAX_VALUE_LENGTH = 1000; private const MAX_VALUE_LENGTH = 1000;
/**
* Never allowed, even when configured: code or command execution, local files, the environment
* of the container (tests*), passwords in the output and stopping other runs (abort).
* The runner (dovecot/imapsync_runner.py) skips the same options.
*/
public const BLOCKED = ['abort', 'delete2foldersbutnot', 'delete2foldersonly', 'logdir', 'logfile', 'maxlinelengthcmd',
'oauthrefreshcmd1', 'oauthrefreshcmd2', 'passfile1', 'passfile2', 'pidfile', 'pipemess', 'regexflag', 'regexmess',
'regextrans2', 'showpasswords', 'skipmess', 'tests', 'testsdebug', 'testslive', 'testslive6', 'testsunit', 'tmpdir'];
private static bool $warnedOldFormat = false; private static bool $warnedOldFormat = false;
@@ -57,7 +65,7 @@ class CustomParams
/** /**
* The effective allowlist: $IMAPSYNC_OPTIONS (after vars.local.inc.php) without the old * The effective allowlist: $IMAPSYNC_OPTIONS (after vars.local.inc.php) without the old
* 'whitelist' key and malformed entries; the defaults when nothing valid is left. * 'whitelist' key, malformed entries and BLOCKED options; the defaults when nothing valid is left.
*/ */
public static function allowlist(array $configured, array $defaults): array public static function allowlist(array $configured, array $defaults): array
{ {
@@ -73,7 +81,7 @@ class CustomParams
self::$warnedOldFormat = true; self::$warnedOldFormat = true;
error_log("mailcow: \$IMAPSYNC_OPTIONS['whitelist'] (old format) is ignored, use 'option' => true|false|'regex' (see data/web/inc/vars.inc.php)"); error_log("mailcow: \$IMAPSYNC_OPTIONS['whitelist'] (old format) is ignored, use 'option' => true|false|'regex' (see data/web/inc/vars.inc.php)");
} }
return empty($allowlist) ? $defaults : $allowlist; return array_diff_key(empty($allowlist) ? $defaults : $allowlist, array_flip(self::BLOCKED));
} }
/** The value to store for one option; throws when the allowlist rule is violated */ /** The value to store for one option; throws when the allowlist rule is violated */
@@ -29,6 +29,7 @@ class Source
public string $host = ''; public string $host = '';
public int $port = 0; public int $port = 0;
public ?string $enc = 'TLS'; public ?string $enc = 'TLS';
public int $verifyCert = 1;
public ?string $authType = 'PLAIN'; public ?string $authType = 'PLAIN';
public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS; public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS;
public ?string $tokenEndpoint = null; public ?string $tokenEndpoint = null;
@@ -65,6 +66,7 @@ class Source
$source->scope = (string)($row['scope'] ?? 'user'); $source->scope = (string)($row['scope'] ?? 'user');
$source->host = (string)($row['host1'] ?? ''); $source->host = (string)($row['host1'] ?? '');
$source->port = (int)($row['port1'] ?? 0); $source->port = (int)($row['port1'] ?? 0);
$source->verifyCert = (int)($row['verify_cert'] ?? 1);
$source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS); $source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS);
$source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null; $source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null;
$source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null; $source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null;
@@ -112,6 +114,7 @@ class Source
'host1' => $this->host, 'host1' => $this->host,
'port1' => $this->port, 'port1' => $this->port,
'enc1' => $this->enc, 'enc1' => $this->enc,
'verify_cert' => $this->verifyCert,
'auth_type' => $this->authType, 'auth_type' => $this->authType,
'oauth_flow' => $this->oauthFlow, 'oauth_flow' => $this->oauthFlow,
'oauth_token_endpoint' => $this->tokenEndpoint, 'oauth_token_endpoint' => $this->tokenEndpoint,
@@ -48,6 +48,12 @@ class SourceRepository extends Repository
return (bool)$this->query("SELECT 1 FROM `imapsync` WHERE `source_id` = :id LIMIT 1", ['id' => $id])->fetchColumn(); return (bool)$this->query("SELECT 1 FROM `imapsync` WHERE `source_id` = :id LIMIT 1", ['id' => $id])->fetchColumn();
} }
/** @return string[] mailboxes whose sync jobs use the source */
public function jobMailboxes(int $id): array
{
return $this->query("SELECT DISTINCT `user2` FROM `imapsync` WHERE `source_id` = :id", ['id' => $id])->fetchAll(PDO::FETCH_COLUMN);
}
public function isSuperAdmin(string $username): bool public function isSuperAdmin(string $username): bool
{ {
return (bool)$this->query("SELECT 1 FROM `admin` WHERE `username` = :username AND `superadmin` = '1'", return (bool)$this->query("SELECT 1 FROM `admin` WHERE `username` = :username AND `superadmin` = '1'",
@@ -200,6 +206,7 @@ class SourceRepository extends Repository
'host1' => $source->host, 'host1' => $source->host,
'port1' => $source->port, 'port1' => $source->port,
'enc1' => $source->enc, 'enc1' => $source->enc,
'verify_cert' => $source->verifyCert,
'auth_type' => $source->authType, 'auth_type' => $source->authType,
'oauth_flow' => $source->oauthFlow, 'oauth_flow' => $source->oauthFlow,
'oauth_token_endpoint' => $source->tokenEndpoint, 'oauth_token_endpoint' => $source->tokenEndpoint,
@@ -121,7 +121,11 @@ class SourceService
if ($writeTargets) { if ($writeTargets) {
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []); $this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
} }
$this->repo->update($source, $writeTargets, self::connection($current) !== self::connection($source)); $connectionChanged = self::connection($current) !== self::connection($source);
if ($connectionChanged || ($current->verifyCert && !$source->verifyCert)) {
$this->requireAllJobsAccessible($source);
}
$this->repo->update($source, $writeTargets, $connectionChanged);
$this->fetchToken($source); $this->fetchToken($source);
return $source; return $source;
} }
@@ -200,6 +204,24 @@ class SourceService
return $this->context->hasAcl('syncjobs') || $this->context->isAdmin(); return $this->context->hasAcl('syncjobs') || $this->context->isAdmin();
} }
/**
* The connection of a source only changes when the caller may access every mailbox whose sync jobs use it,
* their stored passwords would go to the new server.
*
* @throws UserError imapsync_source_used_by_others
*/
private function requireAllJobsAccessible(Source $source): void
{
if ($this->context->hasAdminRights()) {
return;
}
foreach ($this->repo->jobMailboxes((int)$source->id) as $mailbox) {
if (!$this->context->canAccessMailbox($mailbox)) {
throw new UserError('imapsync_source_used_by_others', [htmlspecialchars($source->name)]);
}
}
}
/** @throws AccessDenied when the caller cannot see or edit the source */ /** @throws AccessDenied when the caller cannot see or edit the source */
private function editable(int $id): Source private function editable(int $id): Source
{ {
@@ -274,6 +296,7 @@ class SourceService
$source->host = $host; $source->host = $host;
$source->port = $port; $source->port = $port;
$source->enc = $enc; $source->enc = $enc;
$source->verifyCert = isset($input['verify_cert']) ? (int)!empty($input['verify_cert']) : $source->verifyCert;
$source->authType = $authType; $source->authType = $authType;
$source->active = isset($input['active']) ? (int)$input['active'] : $source->active; $source->active = isset($input['active']) ? (int)$input['active'] : $source->active;
} }
@@ -42,6 +42,8 @@ class Group
public array $sendAs = []; public array $sendAs = [];
/** @var array[] {owner, rights, subscribe, state, last_error}: members may open these mailboxes (ACL group=mcg_<id>) */ /** @var array[] {owner, rights, subscribe, state, last_error}: members may open these mailboxes (ACL group=mcg_<id>) */
public array $imapGrants = []; public array $imapGrants = [];
/** @var string[] owners of removed IMAP grants whose ACL Dovecot still has, not part of the grants to write */
public array $removingImapOwners = [];
/** @var array[] {owner, type, folder, access, subscribe, state, last_error}: SOGo folders shared with the members */ /** @var array[] {owner, type, folder, access, subscribe, state, last_error}: SOGo folders shared with the members */
public array $sogoGrants = []; public array $sogoGrants = [];
/** @var array[] warnings of the last write (msg values), not stored */ /** @var array[] warnings of the last write (msg values), not stored */
@@ -501,6 +501,8 @@ class GroupRepository extends Repository
$group->imapGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]), $group->imapGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
$this->query("SELECT `owner`, `rights`, `subscribe`, `state`, `last_error` FROM `mailbox_group_imap_grant` $this->query("SELECT `owner`, `rights`, `subscribe`, `state`, `last_error` FROM `mailbox_group_imap_grant`
WHERE `group_id` = :id AND `state` != 'removing' ORDER BY `owner`", $id)->fetchAll(PDO::FETCH_ASSOC)); WHERE `group_id` = :id AND `state` != 'removing' ORDER BY `owner`", $id)->fetchAll(PDO::FETCH_ASSOC));
$group->removingImapOwners = $this->query("SELECT `owner` FROM `mailbox_group_imap_grant` WHERE `group_id` = :id AND `state` = 'removing'",
$id)->fetchAll(PDO::FETCH_COLUMN);
$group->sogoGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]), $group->sogoGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
$this->query("SELECT `owner`, `folder_type` AS `type`, `folder`, `access`, `subscribe`, `state`, `last_error` $this->query("SELECT `owner`, `folder_type` AS `type`, `folder`, `access`, `subscribe`, `state`, `last_error`
FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id ORDER BY `owner`, `folder_type`, `folder`", $id)->fetchAll(PDO::FETCH_ASSOC)); FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id ORDER BY `owner`, `folder_type`, `folder`", $id)->fetchAll(PDO::FETCH_ASSOC));
@@ -79,6 +79,12 @@ class GroupService
return false; return false;
} }
} }
// A removed grant still opens the mailbox until Dovecot dropped its ACL
foreach ($group->removingImapOwners as $owner) {
if (!$this->context->canAccessMailbox((string)$owner)) {
return false;
}
}
return true; return true;
} }
@@ -113,7 +119,7 @@ class GroupService
$current = $this->editable($id); $current = $this->editable($id);
$group = clone $current; $group = clone $current;
$this->apply($group, $input, $current); $this->apply($group, $input, $current);
$snapshot = $this->effects->snapshot(array_merge($current->usernames(), $group->usernames())); $snapshot = $this->effects->snapshot($this->inScope(array_merge($current->usernames(), $group->usernames())));
$sogo = $this->repo->update($group); $sogo = $this->repo->update($group);
$group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot), $group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot),
$this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id)); $this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id));
@@ -132,7 +138,7 @@ class GroupService
{ {
$this->requireEditor(); $this->requireEditor();
$group = $this->editable($id); $group = $this->editable($id);
$snapshot = $this->effects->snapshot($group->usernames()); $snapshot = $this->effects->snapshot($this->inScope($group->usernames()));
$this->sharing->removeImap($group); $this->sharing->removeImap($group);
$sogo = $this->repo->delete($id); $sogo = $this->repo->delete($id);
$group->warnings = $this->effects->apply($sogo, $snapshot); $group->warnings = $this->effects->apply($sogo, $snapshot);
@@ -150,13 +156,30 @@ class GroupService
if (!$own && !($this->canRead() && $this->context->canAccessMailbox($username))) { if (!$own && !($this->canRead() && $this->context->canAccessMailbox($username))) {
return []; return [];
} }
$groups = $this->lookup->membershipsOf($username);
// Domain admins see the mailbox's groups in their domains, not those of other domains
if (!$own && !$this->context->hasAdminRights()) {
$groups = array_values(array_filter($groups, fn(array $group): bool => $this->context->canAccessDomain((string)$group['domain'])));
}
return [ return [
'groups' => $this->lookup->membershipsOf($username), 'groups' => $groups,
'template' => $this->templates->effective([$username])[$username] ?? null, 'template' => $this->templates->effective([$username])[$username] ?? null,
'send_as' => $this->lookup->sendAsOf($username), 'send_as' => $this->lookup->sendAsOf($username),
]; ];
} }
/**
* Members whose templates a change of the caller may apply: a domain admin never changes mailboxes outside
* his domains, their new effective template applies with the next admin action or IdP login.
*/
private function inScope(array $usernames): array
{
if ($this->context->hasAdminRights()) {
return $usernames;
}
return array_values(array_filter($usernames, fn(string $username): bool => $this->context->canAccessMailbox($username)));
}
/** @throws AccessDenied when the caller cannot see or edit the group */ /** @throws AccessDenied when the caller cannot see or edit the group */
private function editable(int $id): Group private function editable(int $id): Group
{ {
+4 -2
View File
@@ -19,8 +19,10 @@ if ($iam_provider){
header('Location: ' . $redirect_uri); header('Location: ' . $redirect_uri);
die(); die();
} }
} elseif ($_GET['code'] && $_GET['state'] === $_SESSION['oauth2state']) { } elseif (!empty($_GET['code']) && !empty($_SESSION['oauth2state']) && is_string($_GET['state'] ?? null)
// Check given state against previously stored one to mitigate CSRF attack && hash_equals((string)$_SESSION['oauth2state'], $_GET['state'])) {
// Check given state against previously stored one to mitigate CSRF attack, a state is used once
unset($_SESSION['oauth2state']);
// Received access token in $_GET['code'] // Received access token in $_GET['code']
// extract info and verify user // extract info and verify user
identity_provider('verify-sso'); identity_provider('verify-sso');
+8 -5
View File
@@ -169,6 +169,12 @@ $SESSION_NAME = "MCSESSID";
// Label for OTP devices // Label for OTP devices
$OTP_LABEL = "mailcow UI"; $OTP_LABEL = "mailcow UI";
// Failed second factor logins (TOTP, Yubico OTP, WebAuthn) of an account before its second factor is locked
$TFA_MAX_FAILS = 10;
// Time (in s) from the first failed second factor login until the counter and the lock are reset
$TFA_FAIL_WINDOW = 900;
// How long to wait (in s) for cURL Docker requests // How long to wait (in s) for cURL Docker requests
$DOCKER_TIMEOUT = 60; $DOCKER_TIMEOUT = 60;
@@ -291,13 +297,13 @@ $RSPAMD_MAPS = array(
// true = option requires a non-empty value // true = option requires a non-empty value
// false = pure flag, must NOT have a value // false = pure flag, must NOT have a value
// string = PCRE (no delimiters) the whole non-empty value must match // string = PCRE (no delimiters) the whole non-empty value must match
// Anything not listed here is rejected when a job is saved (the runner passes stored options as they are). // Anything not listed here is rejected when a job is saved; the runner passes stored options as they are, except the blocked ones below.
// Override in vars.local.inc.php per option, e.g. $IMAPSYNC_OPTIONS['someoption'] = true; or unset($IMAPSYNC_OPTIONS['debug']); // Override in vars.local.inc.php per option, e.g. $IMAPSYNC_OPTIONS['someoption'] = true; or unset($IMAPSYNC_OPTIONS['debug']);
// The old format $IMAPSYNC_OPTIONS['whitelist'] (list of names) is no longer supported and ignored (logged). // The old format $IMAPSYNC_OPTIONS['whitelist'] (list of names) is no longer supported and ignored (logged).
// NEVER add (RCE): 'delete2foldersonly', 'delete2foldersbutnot', 'regexflag', 'regexmess', 'pipemess', 'regextrans2', 'maxlinelengthcmd', 'oauthrefreshcmd1', 'oauthrefreshcmd2' // NEVER add (RCE): 'delete2foldersonly', 'delete2foldersbutnot', 'regexflag', 'regexmess', 'pipemess', 'regextrans2', 'maxlinelengthcmd', 'oauthrefreshcmd1', 'oauthrefreshcmd2'
// NEVER add options taking local file paths (e.g. 'logfile', 'logdir', 'pidfile', 'passfile1', 'sslargs1' with SSL_ca_file) // NEVER add options taking local file paths (e.g. 'logfile', 'logdir', 'pidfile', 'passfile1', 'sslargs1' with SSL_ca_file)
// These, the tests* options (print the environment), 'showpasswords' and 'abort' (stops other runs) are also blocked in code
$IMAPSYNC_OPTIONS_DEFAULT = array( $IMAPSYNC_OPTIONS_DEFAULT = array(
'abort' => false,
'authmd51' => false, 'authmd51' => false,
'authuser1' => true, 'authuser1' => true,
'debug' => false, 'debug' => false,
@@ -373,9 +379,6 @@ $IMAPSYNC_OPTIONS_DEFAULT = array(
'syncduplicates' => false, 'syncduplicates' => false,
'syncinternaldates' => false, 'syncinternaldates' => false,
'synclabels' => false, 'synclabels' => false,
'tests' => false,
'testslive' => false,
'testslive6' => false,
'truncmess' => true, 'truncmess' => true,
'usecache' => false, 'usecache' => false,
'useheader' => true, 'useheader' => true,
+5 -1
View File
@@ -184,7 +184,11 @@ var mailcowImapsync = (function($) {
dataSrc: function(json) { dataSrc: function(json) {
if (!Array.isArray(json)) return []; if (!Array.isArray(json)) return [];
$.each(json, function(i, item) { $.each(json, function(i, item) {
item.endpoint = wrapCell(esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')')); item.endpoint = esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')');
if (item.verify_cert == 0 && (item.enc || item.enc1) !== 'PLAIN') {
item.endpoint += ' <i class="bi bi-shield-exclamation text-warning" title="' + esc(L('source_verify_cert_off')) + '"></i>';
}
item.endpoint = wrapCell(item.endpoint);
item.name_display = esc(item.name); item.name_display = esc(item.name);
if (item.description) item.name_display += '<div class="small text-muted">' + esc(item.description) + '</div>'; if (item.description) item.name_display += '<div class="small text-muted">' + esc(item.description) + '</div>';
item.name_display = wrapCell(item.name_display); item.name_display = wrapCell(item.name_display);
+28 -2
View File
@@ -105,9 +105,28 @@ if (isset($_GET['query'])) {
'msg' => 'Cannot find attributes in post data' 'msg' => 'Cannot find attributes in post data'
)); ));
// A login with pending 2FA setup or password update may only use the calls of these steps
if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
if (!empty($_SESSION['pending_tfa_setup'])) {
$pending_allowed = array('get/passwordpolicy', 'get/webauthn-tfa-registration', 'add/webauthn-tfa-registration');
}
else {
// the WebAuthn login of a TFA account comes before the password update
$pending_allowed = array('get/passwordpolicy', 'get/webauthn-tfa-get-args', 'edit/self', 'edit/admin');
}
if (!in_array($action . '/' . $category, $pending_allowed)) {
http_response_code(403);
echo json_encode(array(
'type' => 'error',
'msg' => 'API access denied until the pending account setup is finished'
));
exit();
}
}
switch ($action) { switch ($action) {
case "add": case "add":
if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username'])) { if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username']) || !isset($_SESSION["mailcow_cc_username"])) {
http_response_code(403); http_response_code(403);
echo json_encode(array( echo json_encode(array(
'type' => 'error', 'type' => 'error',
@@ -1549,7 +1568,14 @@ if (isset($_GET['query'])) {
if ($object == "sync-status") { if ($object == "sync-status") {
process_get_return(\Mailcow\Identity\IdentityModule::fromGlobals()->api->getStatus()); process_get_return(\Mailcow\Identity\IdentityModule::fromGlobals()->api->getStatus());
} elseif($_SESSION['mailcow_cc_role'] === 'admin') { } elseif($_SESSION['mailcow_cc_role'] === 'admin') {
process_get_return($iam_settings); $iam_settings_out = $iam_settings;
// Read-only API keys never get the secrets of the identity provider
if (($_SESSION['mailcow_cc_api_access'] ?? '') == 'ro') {
foreach (array('client_secret', 'bindpass', 'access_token') as $iam_secret) {
if (isset($iam_settings_out[$iam_secret])) $iam_settings_out[$iam_secret] = '*';
}
}
process_get_return($iam_settings_out);
} else { } else {
process_get_return(null); process_get_return(null);
} }
+5
View File
@@ -641,6 +641,7 @@
"temp_error": "Temporärer Fehler", "temp_error": "Temporärer Fehler",
"text_empty": "Text darf nicht leer sein", "text_empty": "Text darf nicht leer sein",
"tfa_token_invalid": "TFA-Token ungültig", "tfa_token_invalid": "TFA-Token ungültig",
"tfa_login_locked": "Zu viele fehlgeschlagene Zwei-Faktor-Anmeldungen, bitte später erneut versuchen",
"tfa_removal_blocked": "Zwei-Faktor-Authentifizierung kann nicht entfernt werden, da sie für Ihr Konto erforderlich ist.", "tfa_removal_blocked": "Zwei-Faktor-Authentifizierung kann nicht entfernt werden, da sie für Ihr Konto erforderlich ist.",
"tls_policy_map_dest_invalid": "Ziel ist ungültig", "tls_policy_map_dest_invalid": "Ziel ist ungültig",
"tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits", "tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits",
@@ -666,6 +667,7 @@
"template_name_invalid": "Name der Vorlage ungültig", "template_name_invalid": "Name der Vorlage ungültig",
"required_data_missing": "Die benötigten Daten: %s fehlen", "required_data_missing": "Die benötigten Daten: %s fehlen",
"imapsync_source_in_use": "Sync-Quelle %s wird noch von einem Sync-Job verwendet und kann nicht gelöscht werden", "imapsync_source_in_use": "Sync-Quelle %s wird noch von einem Sync-Job verwendet und kann nicht gelöscht werden",
"imapsync_source_used_by_others": "Der Server der Sync-Quelle %s kann nicht geändert werden, Sync-Jobs anderer Postfächer verwenden sie",
"imapsync_source_token_refresh_failed": "OAuth-Token-Erneuerung für %s fehlgeschlagen", "imapsync_source_token_refresh_failed": "OAuth-Token-Erneuerung für %s fehlgeschlagen",
"imapsync_source_scope_empty": "Für die gewählte Sichtbarkeit mindestens eine Domain wählen", "imapsync_source_scope_empty": "Für die gewählte Sichtbarkeit mindestens eine Domain wählen",
"imapsync_source_unavailable": "Die gewählte Sync-Quelle ist nicht verfügbar", "imapsync_source_unavailable": "Die gewählte Sync-Quelle ist nicht verfügbar",
@@ -1537,6 +1539,9 @@
"source_tenant_hint": "Verzeichnis-ID (Mandant) oder eine Domain des Tenants, z. B. contoso.onmicrosoft.com. Wird in die Endpunkte eingesetzt.", "source_tenant_hint": "Verzeichnis-ID (Mandant) oder eine Domain des Tenants, z. B. contoso.onmicrosoft.com. Wird in die Endpunkte eingesetzt.",
"source_scope_cc_hint": "Quellen mit Client-Credentials-Flow sind immer nur für ihren Ersteller sichtbar.", "source_scope_cc_hint": "Quellen mit Client-Credentials-Flow sind immer nur für ihren Ersteller sichtbar.",
"source_readonly_hint": "Diese Sync-Quelle ist freigegeben und kann nur angesehen werden.", "source_readonly_hint": "Diese Sync-Quelle ist freigegeben und kann nur angesehen werden.",
"source_verify_cert": "Zertifikat des Servers prüfen",
"source_verify_cert_hint": "Gilt für SSL und STARTTLS. Für Server mit selbstsigniertem, abgelaufenem oder nicht zum Host passendem Zertifikat abschalten.",
"source_verify_cert_off": "Das Zertifikat des Servers wird nicht geprüft",
"copy": "Kopieren", "copy": "Kopieren",
"copied": "In die Zwischenablage kopiert", "copied": "In die Zwischenablage kopiert",
"source_view": "Anzeigen", "source_view": "Anzeigen",
+5
View File
@@ -517,6 +517,7 @@
"group_send_as_invalid": "Send-as address %s is invalid or unknown", "group_send_as_invalid": "Send-as address %s is invalid or unknown",
"iam_sync_mode_invalid": "Invalid sync mode", "iam_sync_mode_invalid": "Invalid sync mode",
"iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)", "iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)",
"tfa_login_locked": "Too many failed two-factor logins, please try again later",
"tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.", "tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.",
"alias_domain_invalid": "Alias domain %s is invalid", "alias_domain_invalid": "Alias domain %s is invalid",
"alias_empty": "Alias address must not be empty", "alias_empty": "Alias address must not be empty",
@@ -666,6 +667,7 @@
"version_invalid": "Version %s is invalid", "version_invalid": "Version %s is invalid",
"yotp_verification_failed": "Yubico OTP verification failed: %s", "yotp_verification_failed": "Yubico OTP verification failed: %s",
"imapsync_source_in_use": "Sync source %s is still used by a sync job and cannot be deleted", "imapsync_source_in_use": "Sync source %s is still used by a sync job and cannot be deleted",
"imapsync_source_used_by_others": "The server of sync source %s cannot be changed, sync jobs of other mailboxes use it",
"imapsync_source_token_refresh_failed": "OAuth token refresh for %s failed", "imapsync_source_token_refresh_failed": "OAuth token refresh for %s failed",
"imapsync_source_scope_empty": "Select at least one domain for the chosen visibility", "imapsync_source_scope_empty": "Select at least one domain for the chosen visibility",
"imapsync_source_unavailable": "The selected sync source is unavailable", "imapsync_source_unavailable": "The selected sync source is unavailable",
@@ -1544,6 +1546,9 @@
"source_tenant_hint": "Directory (tenant) ID or a domain of the tenant, e.g. contoso.onmicrosoft.com. It is inserted into the endpoints.", "source_tenant_hint": "Directory (tenant) ID or a domain of the tenant, e.g. contoso.onmicrosoft.com. It is inserted into the endpoints.",
"source_scope_cc_hint": "Sources using the client credentials flow are always private to their creator.", "source_scope_cc_hint": "Sources using the client credentials flow are always private to their creator.",
"source_readonly_hint": "This sync source is shared with you and can only be viewed.", "source_readonly_hint": "This sync source is shared with you and can only be viewed.",
"source_verify_cert": "Verify the server certificate",
"source_verify_cert_hint": "For SSL and STARTTLS. Turn off for servers with a self-signed or expired certificate or a certificate that does not match the host.",
"source_verify_cert_off": "The server certificate is not verified",
"copy": "Copy", "copy": "Copy",
"copied": "Copied to clipboard", "copied": "Copied to clipboard",
"source_view": "Show", "source_view": "Show",
+5 -1
View File
@@ -12,6 +12,10 @@ if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'use
header('Location: index.php?mobileconfig' . $append_get); header('Location: index.php?mobileconfig' . $append_get);
die(); die();
} }
if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
header('Location: /');
die();
}
error_reporting(0); error_reporting(0);
@@ -39,7 +43,7 @@ if (isset($_GET['only_email'])) {
$onlyEmailAccount = false; $onlyEmailAccount = false;
$description = 'IMAP, CalDAV, CardDAV'; $description = 'IMAP, CalDAV, CardDAV';
} }
if (isset($_GET['app_password'])) { if (isset($_GET['app_password']) && ($_SESSION['acl']['app_passwds'] ?? '0') == '1') {
$app_password = true; $app_password = true;
$description .= ' with application password'; $description .= ' with application password';
+3 -1
View File
@@ -35,7 +35,9 @@ if (empty($_SESSION['mailcow_cc_username']) || empty($_SESSION['mailcow_cc_role'
} }
$context = \Mailcow\Core\Context::fromSession($_SESSION); $context = \Mailcow\Core\Context::fromSession($_SESSION);
$imapsync = \Mailcow\Imapsync\ImapsyncModule::fromGlobals($context); $imapsync = \Mailcow\Imapsync\ImapsyncModule::fromGlobals($context);
if (!$context->hasAcl('syncjobs')) { // Storing a token is a write: no read-only API keys, no session with pending 2FA setup or password update
if (!$context->hasAcl('syncjobs') || $context->isReadOnlyApi()
|| !empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
syncjob_oauth_finish(false, array('error' => 'access_denied')); syncjob_oauth_finish(false, array('error' => 'access_denied'));
} }
@@ -12,6 +12,7 @@
{% endif %} {% endif %}
<form class="form-horizontal" data-id="edit_imapsync_source" role="form" method="post"> <form class="form-horizontal" data-id="edit_imapsync_source" role="form" method="post">
<input type="hidden" value="0" name="active"> <input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="verify_cert">
<div class="row mb-2"> <div class="row mb-2">
<label class="control-label col-sm-2" for="name">{{ lang.syncjobs.source_name }}</label> <label class="control-label col-sm-2" for="name">{{ lang.syncjobs.source_name }}</label>
<div class="col-sm-10"> <div class="col-sm-10">
@@ -99,6 +100,14 @@
</select> </select>
</div> </div>
</div> </div>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="verify_cert"{% if result.verify_cert == '1' %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.syncjobs.source_verify_cert }}</label>
</div>
<small class="text-muted">{{ lang.syncjobs.source_verify_cert_hint }}</small>
</div>
</div>
<div class="row mb-4"> <div class="row mb-4">
<label class="control-label col-sm-2" for="auth_type">{{ lang.syncjobs.source_auth_type }}</label> <label class="control-label col-sm-2" for="auth_type">{{ lang.syncjobs.source_auth_type }}</label>
<div class="col-sm-10"> <div class="col-sm-10">
@@ -43,7 +43,7 @@
</td> </td>
<td class="text-end"> <td class="text-end">
{% if not ro %} {% if not ro %}
<button class="btn btn-sm btn-secondary group-grant-del" title="{{ lang.mailbox.remove }}"><i class="bi bi-x-lg"></i></button> <button type="button" class="btn btn-sm btn-secondary group-grant-del" title="{{ lang.mailbox.remove }}"><i class="bi bi-x-lg"></i></button>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -17,7 +17,7 @@
<template id="{{ id }}_template"> <template id="{{ id }}_template">
{% include 'edit/mailbox-group-grant.twig' with {'kind': kind, 'grant': {}} %} {% include 'edit/mailbox-group-grant.twig' with {'kind': kind, 'grant': {}} %}
</template> </template>
<button class="btn btn-sm btn-secondary group-grant-add" data-table="{{ id }}"><i class="bi bi-plus-lg"></i> {{ lang.groups.grant_add }}</button> <button type="button" class="btn btn-sm btn-secondary group-grant-add" data-table="{{ id }}"><i class="bi bi-plus-lg"></i> {{ lang.groups.grant_add }}</button>
{% endif %} {% endif %}
<small class="text-muted d-block">{{ attribute(lang.groups, field ~ '_hint') }}</small> <small class="text-muted d-block">{{ attribute(lang.groups, field ~ '_hint') }}</small>
</div> </div>
@@ -9,6 +9,7 @@
<div class="modal-body"> <div class="modal-body">
<form class="form-horizontal" data-cached-form="true" role="form" data-id="add_imapsync_source"> <form class="form-horizontal" data-cached-form="true" role="form" data-id="add_imapsync_source">
<input type="hidden" value="0" name="active"> <input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="verify_cert">
<div class="row mb-3"> <div class="row mb-3">
<label class="control-label col-sm-2 text-sm-end">{{ lang.syncjobs.source_presets }}</label> <label class="control-label col-sm-2 text-sm-end">{{ lang.syncjobs.source_presets }}</label>
<div class="col-sm-10 small"> <div class="col-sm-10 small">
@@ -99,6 +100,14 @@
</select> </select>
</div> </div>
</div> </div>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="verify_cert" checked> {{ lang.syncjobs.source_verify_cert }}</label>
</div>
<small class="text-muted">{{ lang.syncjobs.source_verify_cert_hint }}</small>
</div>
</div>
<div class="row mb-4"> <div class="row mb-4">
<label class="control-label col-sm-2 text-sm-end" for="addsrc_auth_type">{{ lang.syncjobs.source_auth_type }}</label> <label class="control-label col-sm-2 text-sm-end" for="addsrc_auth_type">{{ lang.syncjobs.source_auth_type }}</label>
<div class="col-sm-10"> <div class="col-sm-10">
@@ -77,6 +77,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if acl.app_passwds == 1 %}
<div class="row mt-2"> <div class="row mt-2">
<div class="col-12 col-md-3 d-flex"> <div class="col-12 col-md-3 d-flex">
<span class="mt-2 w-100 text-md-end">{{ lang.user.apple_connection_profile }}<br>{{ lang.user.with_app_password }}:</span> <span class="mt-2 w-100 text-md-end">{{ lang.user.apple_connection_profile }}<br>{{ lang.user.with_app_password }}:</span>
@@ -94,6 +95,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %}
<div class="row"> <div class="row">
<div class="col-12 d-flex flex-column text-center align-items-center mt-4"> <div class="col-12 d-flex flex-column text-center align-items-center mt-4">
<a target="_blank" href="https://mailcow.github.io/mailcow-dockerized-docs/client/client/#{{ clientconfigstr }}">{{ lang.user.client_configuration }}</a> <a target="_blank" href="https://mailcow.github.io/mailcow-dockerized-docs/client/client/#{{ clientconfigstr }}">{{ lang.user.client_configuration }}</a>
+12 -9
View File
@@ -1,5 +1,5 @@
# Version of the database schema # Version of the database schema
x-db-version: &db_version DB_VERSION=28102026_1300 x-db-version: &db_version DB_VERSION=02112026_1600
services: services:
@@ -194,13 +194,13 @@ services:
ofelia.enabled: "true" ofelia.enabled: "true"
ofelia.job-exec.phpfpm_identity_sync.schedule: "0 * * * * *" ofelia.job-exec.phpfpm_identity_sync.schedule: "0 * * * * *"
ofelia.job-exec.phpfpm_identity_sync.no-overlap: "true" ofelia.job-exec.phpfpm_identity_sync.no-overlap: "true"
ofelia.job-exec.phpfpm_identity_sync.command: "/bin/bash -c \"php /crons/identity-sync.php || exit 0\"" ofelia.job-exec.phpfpm_identity_sync.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && php /crons/identity-sync.php || exit 0\""
ofelia.job-exec.phpfpm_group_grants.schedule: "0 * * * * *" ofelia.job-exec.phpfpm_group_grants.schedule: "0 * * * * *"
ofelia.job-exec.phpfpm_group_grants.no-overlap: "true" ofelia.job-exec.phpfpm_group_grants.no-overlap: "true"
ofelia.job-exec.phpfpm_group_grants.command: "/bin/bash -c \"php /crons/group-grants.php || exit 0\"" ofelia.job-exec.phpfpm_group_grants.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && php /crons/group-grants.php || exit 0\""
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.schedule: "0 * * * * *" ofelia.job-exec.phpfpm_imapsync_oauth_refresh.schedule: "0 * * * * *"
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.no-overlap: "true" ofelia.job-exec.phpfpm_imapsync_oauth_refresh.no-overlap: "true"
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.command: "/bin/bash -c \"php /crons/imapsync-oauth-refresh.php || exit 0\"" ofelia.job-exec.phpfpm_imapsync_oauth_refresh.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && php /crons/imapsync-oauth-refresh.php || exit 0\""
networks: networks:
mailcow-network: mailcow-network:
aliases: aliases:
@@ -259,7 +259,7 @@ services:
- sogo - sogo
dovecot-mailcow: dovecot-mailcow:
image: ghcr.io/mailcow/dovecot:2.3.21.1-3 image: ghcr.io/mailcow/dovecot:2.3.21.1-4
depends_on: depends_on:
- mysql-mailcow - mysql-mailcow
- netfilter-mailcow - netfilter-mailcow
@@ -295,6 +295,7 @@ services:
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} - MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- MAILCOW_PASS_SCHEME=${MAILCOW_PASS_SCHEME:-BLF-CRYPT} - MAILCOW_PASS_SCHEME=${MAILCOW_PASS_SCHEME:-BLF-CRYPT}
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1} - IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
- ALLOW_ADMIN_EMAIL_LOGIN=${ALLOW_ADMIN_EMAIL_LOGIN:-n} - ALLOW_ADMIN_EMAIL_LOGIN=${ALLOW_ADMIN_EMAIL_LOGIN:-n}
- MAILDIR_GC_TIME=${MAILDIR_GC_TIME:-7200} - MAILDIR_GC_TIME=${MAILDIR_GC_TIME:-7200}
- ACL_ANYONE=${ACL_ANYONE:-disallow} - ACL_ANYONE=${ACL_ANYONE:-disallow}
@@ -427,7 +428,7 @@ services:
- php-fpm-mailcow - php-fpm-mailcow
- sogo-mailcow - sogo-mailcow
- rspamd-mailcow - rspamd-mailcow
image: ghcr.io/mailcow/nginx:1.30.6-1 image: ghcr.io/mailcow/nginx:1.30.6-2
dns: dns:
- ${IPV4_NETWORK:-172.22.1}.254 - ${IPV4_NETWORK:-172.22.1}.254
environment: environment:
@@ -447,6 +448,7 @@ services:
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1} - IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n} - NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-} - TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
- NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE:-128m}
volumes: volumes:
- ./data/web:/web:ro,z - ./data/web:/web:ro,z
- ./data/conf/rspamd/dynmaps:/dynmaps:ro,z - ./data/conf/rspamd/dynmaps:/dynmaps:ro,z
@@ -514,7 +516,7 @@ services:
- acme - acme
netfilter-mailcow: netfilter-mailcow:
image: ghcr.io/mailcow/netfilter:1.66 image: ghcr.io/mailcow/netfilter:1.67
stop_grace_period: 30s stop_grace_period: 30s
restart: always restart: always
privileged: true privileged: true
@@ -534,7 +536,7 @@ services:
- /lib/modules:/lib/modules:ro - /lib/modules:/lib/modules:ro
watchdog-mailcow: watchdog-mailcow:
image: ghcr.io/mailcow/watchdog:2.12 image: ghcr.io/mailcow/watchdog:2.13
dns: dns:
- ${IPV4_NETWORK:-172.22.1}.254 - ${IPV4_NETWORK:-172.22.1}.254
tmpfs: tmpfs:
@@ -552,6 +554,7 @@ services:
- acme-mailcow - acme-mailcow
- redis-mailcow - redis-mailcow
environment: environment:
- *db_version
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64} - IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
- LOG_LINES=${LOG_LINES:-9999} - LOG_LINES=${LOG_LINES:-9999}
- TZ=${TZ} - TZ=${TZ}
@@ -609,7 +612,7 @@ services:
- watchdog - watchdog
dockerapi-mailcow: dockerapi-mailcow:
image: ghcr.io/mailcow/dockerapi:2.14 image: ghcr.io/mailcow/dockerapi:2.15
security_opt: security_opt:
- label=disable - label=disable
restart: always restart: always
+4
View File
@@ -215,6 +215,10 @@ HTTPS_BIND=
# Redirect HTTP connections to HTTPS - y/n # Redirect HTTP connections to HTTPS - y/n
HTTP_REDIRECT=y HTTP_REDIRECT=y
# Maximum request size for SOGo webmail, CalDAV/CardDAV and ActiveSync, e.g. 128m or 1g - 0 means unlimited
# Keep it above the message size limit of Postfix (100 MB by default). The mailcow UI and API stay limited to 64m.
NGINX_MAX_BODY_SIZE=128m
# ------------------------------ # ------------------------------
# Other bindings # Other bindings
# ------------------------------ # ------------------------------
-19
View File
@@ -188,25 +188,6 @@ class BanManagerTest(unittest.TestCase):
self.bans.purge() self.bans.purge()
self.assertEqual(self.bans.counters, {}) self.assertEqual(self.bans.counters, {})
def test_excused_address_is_not_counted(self):
ip = ipaddress.ip_address('203.0.113.1')
self.bans.excuse(ip)
self.bans.excuse(ip)
self.attempts('203.0.113.1', 5)
self.assertEqual(self.active(), {})
self.assertEqual(len([message for message in self.logger.messages('warn') if 'Auth backend unavailable' in message]), 1)
# other addresses are counted
self.attempts('203.0.113.2', 3)
self.assertIn('203.0.113.2/32', self.active())
def test_excuse_ends(self):
self.bans.excuse(ipaddress.ip_address('203.0.113.1'))
self.clock.now += BanManager.EXCUSE_TIME + 1
self.bans.purge()
self.assertEqual(self.bans.excused, {})
self.attempts('203.0.113.1', 3)
self.assertIn('203.0.113.1/32', self.active())
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+23 -24
View File
@@ -25,8 +25,8 @@ class NetfilterTest(unittest.TestCase):
netfilter.regexes.refresh(self.r) netfilter.regexes.refresh(self.r)
return netfilter return netfilter
def sasl(self, address): def failed(self, address):
return 'warning: unknown[%s]: SASL LOGIN authentication failed: UGFzc3dvcmQ6' % address return 'MAILCOWAUTH: Login failed for user a@example.org with service imap from IP %s' % address
def test_setup_restores_bans_and_resets_the_firewall(self): def test_setup_restores_bans_and_resets_the_firewall(self):
self.r.hset('F2B_ACTIVE_BANS', '1.2.3.4/32', '9999999999') self.r.hset('F2B_ACTIVE_BANS', '1.2.3.4/32', '9999999999')
@@ -49,8 +49,8 @@ class NetfilterTest(unittest.TestCase):
def test_shutdown_keeps_the_active_bans(self): def test_shutdown_keeps_the_active_bans(self):
netfilter = self.create() netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
self.r.hset('F2B_PERM_BANS', '198.51.100.1', '1') self.r.hset('F2B_PERM_BANS', '198.51.100.1', '1')
netfilter.shutdown() netfilter.shutdown()
self.assertEqual(self.tables.rules, set()) self.assertEqual(self.tables.rules, set())
@@ -59,52 +59,51 @@ class NetfilterTest(unittest.TestCase):
def test_handle_line_bans(self): def test_handle_line_bans(self):
netfilter = self.create() netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
self.assertEqual(self.tables.rules, {'1.2.3.4/32'}) self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
self.assertIn('1.2.3.4 matched rule id 3 (%s)' % self.sasl('1.2.3.4'), self.logger.messages('warn')) self.assertIn('1.2.3.4 matched rule id 3 (%s)' % self.failed('1.2.3.4'), self.logger.messages('warn'))
def test_handle_line_with_trailing_colon(self): def test_handle_line_with_trailing_colon(self):
netfilter = self.create() netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4:')) netfilter.handle_line(self.failed('1.2.3.4:'))
netfilter.handle_line(self.sasl('1.2.3.4:')) netfilter.handle_line(self.failed('1.2.3.4:'))
self.assertEqual(self.tables.rules, {'1.2.3.4/32'}) self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
def test_handle_line_ignores_private_and_invalid_addresses(self): def test_handle_line_ignores_private_and_invalid_addresses(self):
netfilter = self.create() netfilter = self.create()
for _ in range(3): for _ in range(3):
netfilter.handle_line(self.sasl('192.168.1.1')) netfilter.handle_line(self.failed('192.168.1.1'))
netfilter.handle_line(self.sasl('::1')) netfilter.handle_line(self.failed('::1'))
netfilter.handle_line(self.sasl('1.2.3')) netfilter.handle_line(self.failed('1.2.3'))
self.assertEqual(self.tables.rules, set()) self.assertEqual(self.tables.rules, set())
self.assertTrue(any('invalid address 1.2.3' in message for message in self.logger.messages('warn'))) self.assertTrue(any('invalid address 1.2.3' in message for message in self.logger.messages('warn')))
def test_handle_line_with_replaced_characters(self): def test_handle_line_with_replaced_characters(self):
netfilter = self.create() netfilter = self.create()
line = b'warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: \xff\xfe'.decode('utf-8', 'replace') line = b'MAILCOWAUTH: Login failed for user \xff\xfe with service imap from IP 1.2.3.4'.decode('utf-8', 'replace')
netfilter.handle_line(line) netfilter.handle_line(line)
netfilter.handle_line(line) netfilter.handle_line(line)
self.assertEqual(self.tables.rules, {'1.2.3.4/32'}) self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
def test_unavailable_auth_backend_is_no_failed_login(self): def test_postfix_and_dovecot_lines_are_no_failed_logins(self):
# Dovecot logs the temporary failure, Postfix then the failed SASL login like for a wrong password # mailcowauth.php reports failed logins, not when the auth backend was unavailable
netfilter = self.create() netfilter = self.create()
netfilter.handle_line('auth-worker(1): Error: conn unix:auth-worker (pid=2,uid=401): auth-worker<4>: '
'lua(a@example.org,1.2.3.4): passdb-lua: Upstream unavailable')
for _ in range(3): for _ in range(3):
netfilter.handle_line('warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org') netfilter.handle_line('warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org')
netfilter.handle_line('imap-login: Disconnected: Connection closed (auth failed, 1 attempts in 2 secs): user=<a@example.org>, '
'method=PLAIN, rip=1.2.3.4, lip=172.22.1.250')
self.assertEqual(self.tables.rules, set()) self.assertEqual(self.tables.rules, set())
self.assertNotIn('1.2.3.4/32', self.r.hgetall('F2B_ACTIVE_BANS'))
def test_handle_line_survives_errors(self): def test_handle_line_survives_errors(self):
netfilter = self.create() netfilter = self.create()
with mock.patch.object(netfilter.bans, 'attempt', side_effect=RuntimeError('boom')): with mock.patch.object(netfilter.bans, 'attempt', side_effect=RuntimeError('boom')):
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
self.assertTrue(any('boom' in message for message in self.logger.messages('warn'))) self.assertTrue(any('boom' in message for message in self.logger.messages('warn')))
def test_watch_reconnects_after_errors(self): def test_watch_reconnects_after_errors(self):
netfilter = self.create() netfilter = self.create()
self.r.messages.extend([self.sasl('1.2.3.4'), ConnectionError('gone'), self.sasl('1.2.3.4')]) self.r.messages.extend([self.failed('1.2.3.4'), ConnectionError('gone'), self.failed('1.2.3.4')])
netfilter.loop('watch', netfilter.watch, 0) netfilter.loop('watch', netfilter.watch, 0)
self.assertEqual(self.tables.rules, {'1.2.3.4/32'}) self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
self.assertEqual(len(self.r.pubsubs), 2) self.assertEqual(len(self.r.pubsubs), 2)
@@ -126,8 +125,8 @@ class NetfilterTest(unittest.TestCase):
def test_recreated_chain_gets_isolation_and_bans_again(self): def test_recreated_chain_gets_isolation_and_bans_again(self):
netfilter = self.create() netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
self.tables.rules = set() self.tables.rules = set()
self.tables.calls = [] self.tables.calls = []
self.tables.recreate = True self.tables.recreate = True
@@ -151,8 +150,8 @@ class NetfilterTest(unittest.TestCase):
self.r.hset('F2B_BLACKLIST', '198.51.100.1', 1) self.r.hset('F2B_BLACKLIST', '198.51.100.1', 1)
netfilter.update_lists() netfilter.update_lists()
self.assertEqual(self.tables.rules, {'198.51.100.1/32'}) self.assertEqual(self.tables.rules, {'198.51.100.1/32'})
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4')) netfilter.handle_line(self.failed('1.2.3.4'))
self.assertNotIn('1.2.3.4/32', self.tables.rules) self.assertNotIn('1.2.3.4/32', self.tables.rules)
def test_resolve_keeps_addresses_on_dns_errors(self): def test_resolve_keeps_addresses_on_dns_errors(self):
+103 -17
View File
@@ -10,14 +10,35 @@ class RegexesTest(unittest.TestCase):
LINES = { LINES = {
'1': ('mailcow UI: Invalid password for admin by 203.0.113.5', '203.0.113.5'), '1': ('mailcow UI: Invalid password for admin by 203.0.113.5', '203.0.113.5'),
'2': ('Rspamd UI: Invalid password by 2001:db8::5', '2001:db8::5'), '2': ('Rspamd UI: Invalid password by 2001:db8::5', '2001:db8::5'),
'3': ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: UGFzc3dvcmQ6', '203.0.113.6'), '3': ('MAILCOWAUTH: Login failed for user user@example.org with service smtp from IP 203.0.113.6', '203.0.113.6'),
'4': ('warning: non-SMTP command from unknown[203.0.113.7]: GET / HTTP/1.1', '203.0.113.7'), '4': ('warning: non-SMTP command from unknown[203.0.113.7]: GET _ HTTP_1.1', '203.0.113.7'),
'5': ('NOQUEUE: reject: RCPT from [203.0.113.8]: 503 5.5.0 <a@example.org>: Protocol error; proto=ESMTP', '203.0.113.8'), '5': ('NOQUEUE: reject: RCPT from [203.0.113.8]:52516: 550 5.5.1 Protocol error; from=<a@example.org> to=<b@example.org>',
'6': ('auth: lua(user@example.org,203.0.113.9,<abcdef>): Password mismatch (SHA1 of given password: 0123ab)', '203.0.113.9'), '203.0.113.8'),
'7': ('auth: lua(nobody@example.org,203.0.113.10,<abcdef>): unknown user (SHA1 of given password: 0123ab)', '203.0.113.10'), '6': ("[6714]: SOGoRootPage Login from '203.0.113.11' for user 'nobody@example.org' might not have worked - "
'8': ("SOGo[123]: Login from '203.0.113.11' for user nobody might not have worked", '203.0.113.11'), "password policy: 65535 grace: -1 expire: -1 bound: 0", '203.0.113.11'),
'9': ('203.0.113.12 "GET /SOGo/so/ HTTP/1.1" 403 123 "-"', '203.0.113.12'), '7': ('203.0.113.12 "GET /SOGo/so/ HTTP/1.1" 403 123 "-"', '203.0.113.12'),
'8': ('auth: passwd-file(master@mailcow.local,203.0.113.13,master,<qIIX9EZfYISsFgEB>): '
'Password mismatch (SHA1 of given password: 05d839)', '203.0.113.13'),
} }
# Defaults of 2025-02 to 2026-10 and of 2022-11 to 2025-02
FORMER = {
'1': r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)',
'2': r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)',
'3': r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+',
'4': r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+',
'5': r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
'6': r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)',
'7': r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)',
'8': r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
'9': r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
}
OLDER = dict(FORMER, **{
'6': r'-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),',
'7': r'-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+',
'8': r'-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+',
'9': r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
'10': r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
})
def setUp(self): def setUp(self):
self.r = FakeRedis() self.r = FakeRedis()
@@ -30,10 +51,82 @@ class RegexesTest(unittest.TestCase):
for rule_id, (line, address) in self.LINES.items(): for rule_id, (line, address) in self.LINES.items():
self.assertEqual(self.regexes.match(line), (rule_id, address), line) self.assertEqual(self.regexes.match(line), (rule_id, address), line)
def test_lost_connection_to_auth_server_is_no_attempt(self): def test_postfix_and_dovecot_lines_are_no_attempts(self):
# mailcowauth.php reports the failed login once, also when Postfix asked Dovecot
self.regexes.refresh(self.r) self.regexes.refresh(self.r)
line = 'warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: Connection lost to authentication server' for line in ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org',
self.assertIsNone(self.regexes.match(line)) 'auth-worker(37358): conn unix:auth-worker (pid=577,uid=401): auth-worker<25>: '
'lua(a@example.org,203.0.113.6,<j4rbQeReAqwqAPggBBcAAAAFAAEAdgES>): Password mismatch (SHA1 of given password: 6426a0)',
'imap-login: Disconnected: Connection closed (auth failed, 1 attempts in 2 secs): user=<a@example.org>, '
'method=PLAIN, rip=203.0.113.6, lip=172.22.1.250'):
self.assertIsNone(self.regexes.match(line), line)
def test_failed_master_logins(self):
# Dovecot checks the master passdb itself, mailcowauth.php is not asked
self.regexes.refresh(self.r)
self.assertEqual(self.regexes.match('auth: passwd-file(x@mailcow.local,2001:db8::13,master,<dfbigUdfZJysFgEB>): '
'unknown user (SHA1 of given password: 6d0653)'), ('8', '2001:db8::13'))
# no session of Postfix
self.assertEqual(self.regexes.match('auth: passwd-file(x@mailcow.local,203.0.113.13,master): '
'Password mismatch (SHA1 of given password: 6d0653)'), ('8', '203.0.113.13'))
# without the client address (doveadm) or for the user of a master login
self.assertIsNone(self.regexes.match('auth: passwd-file(x@mailcow.local,master,<dfbigUdfZJysFgEB>): Password mismatch'))
self.assertIsNone(self.regexes.match('auth: lua(a@example.org,203.0.113.13,master,<dfbigUdfZJysFgEB>): unknown user'))
def test_text_of_clients_does_not_match(self):
# Login names, SMTP commands and addresses of other lines must not ban a foreign address
self.regexes.refresh(self.r)
self.assertEqual(self.regexes.match('warning: non-SMTP command from unknown[198.51.100.7]: '
'mailcow UI: Invalid password for x by 203.0.113.1'), ('4', '198.51.100.7'))
self.assertEqual(self.regexes.match('warning: non-SMTP command from unknown[198.51.100.7]: x [203.0.113.1]: y'),
('4', '198.51.100.7'))
self.assertIsNone(self.regexes.match('NOQUEUE: reject: RCPT from unknown[198.51.100.7]: 554 5.7.1 Relay access denied; '
'from=<NOQUEUE: reject: RCPT from [203.0.113.1] Protocol error x>'))
self.assertIsNone(self.regexes.match('improper command pipelining after EHLO from unknown[198.51.100.7]: '
'MAILCOWAUTH: Login failed for user x with service imap from IP 203.0.113.1'))
self.assertEqual(self.regexes.match('MAILCOWAUTH: Login failed for user x with service imap from IP 203.0.113.1 '
'with service smtp from IP 198.51.100.7'),
('3', '198.51.100.7'))
self.assertEqual(self.regexes.match("[1]: SOGoRootPage Login from '198.51.100.7' for user 'x' might not have worked "
"SOGoRootPage Login from '203.0.113.1' for user y might not have worked"), ('6', '198.51.100.7'))
# nginx appends the client to the X-Forwarded-For header a client sent
self.assertEqual(self.regexes.match("[1]: SOGoRootPage Login from '203.0.113.1, 198.51.100.7' for user 'x' might not have worked"),
('6', '198.51.100.7'))
# the address Dovecot adds follows the login name
self.assertEqual(self.regexes.match('auth: passwd-file(x,203.0.113.1,master,<a>): Password mismatch,198.51.100.7,master,<b>): '
'Password mismatch (SHA1 of given password: 6d0653)'), ('8', '198.51.100.7'))
# lines of the web UI are not sanitized like syslog lines
self.assertEqual(self.regexes.match('mailcow UI: Invalid password for admin\n by 203.0.113.5'), ('1', '203.0.113.5'))
def test_former_defaults_are_replaced(self):
self.r.set('F2B_REGEX', json.dumps(self.FORMER))
self.regexes.refresh(self.r)
defaults = {str(rule_id): regex for rule_id, regex in Regexes.DEFAULTS.items()}
# the rule for failed Dovecot logins counts failed master logins now
expected = dict(defaults, **{'6': defaults['8'], '8': defaults['6'], '9': defaults['7']})
del expected['7']
self.assertEqual(json.loads(self.r.get('F2B_REGEX')), expected)
self.assertEqual(self.regexes.match(self.LINES['3'][0]), ('3', '203.0.113.6'))
self.assertEqual(self.regexes.match(self.LINES['6'][0]), ('8', '203.0.113.11'))
self.assertEqual(self.regexes.match(self.LINES['8'][0]), ('6', '203.0.113.13'))
def test_older_defaults_are_replaced(self):
self.r.set('F2B_REGEX', json.dumps(self.OLDER))
self.regexes.refresh(self.r)
stored = json.loads(self.r.get('F2B_REGEX'))
self.assertEqual(sorted(stored, key=int), ['1', '2', '3', '4', '5', '9', '10'])
self.assertEqual(stored['9'], Regexes.DEFAULTS[6])
self.assertEqual(stored['10'], self.OLDER['10'])
def test_changed_rules_are_kept(self):
custom = {'1': r'mailcow UI: Invalid password for (?:x|y) by ([0-9a-f\.:]+)', '2': self.FORMER['3'], '3': r'custom ([0-9\.]+)'}
self.r.set('F2B_REGEX', json.dumps(custom))
self.regexes.refresh(self.r)
self.assertEqual(json.loads(self.r.get('F2B_REGEX')), dict(custom, **{'2': Regexes.DEFAULTS[3]}))
# current rules are not written again
raw = self.r.get('F2B_REGEX')
self.regexes.refresh(self.r)
self.assertEqual(self.r.get('F2B_REGEX'), raw)
def test_only_the_first_matching_rule_counts(self): def test_only_the_first_matching_rule_counts(self):
self.r.set('F2B_REGEX', json.dumps({'2': r'by ([0-9a-f\.:]+)', '10': r'from ([0-9a-f\.:]+)', '1': r'login from ([0-9a-f\.:]+)'})) self.r.set('F2B_REGEX', json.dumps({'2': r'by ([0-9a-f\.:]+)', '10': r'from ([0-9a-f\.:]+)', '1': r'login from ([0-9a-f\.:]+)'}))
@@ -58,13 +151,6 @@ class RegexesTest(unittest.TestCase):
self.regexes.refresh(self.r) self.regexes.refresh(self.r)
self.assertIsNone(self.regexes.match('failed by nobody')) self.assertIsNone(self.regexes.match('failed by nobody'))
def test_temporary_failure(self):
worker = 'auth-worker(3933): Error: conn unix:auth-worker (pid=3932,uid=401): auth-worker<4>: '
self.assertEqual(Regexes.temporary_failure(worker + 'lua(a@example.org,203.0.113.1,<GGexLdBezoasFgEB>): passdb-lua: Upstream unavailable'), '203.0.113.1')
self.assertEqual(Regexes.temporary_failure(worker + 'lua(a@example.org,2001:db8::1): passdb-lua: Upstream unreachable'), '2001:db8::1')
self.assertIsNone(Regexes.temporary_failure('auth: lua(a@example.org,203.0.113.1,<GGexLdBezoasFgEB>): unknown user (SHA1 of given password: 0123ab)'))
self.assertIsNone(Regexes.temporary_failure('warning: unknown[203.0.113.1]: SASL LOGIN authentication failed: (reason unavailable)'))
def test_parse_ip(self): def test_parse_ip(self):
self.assertEqual(Regexes.parse_ip('203.0.113.1'), ipaddress.ip_address('203.0.113.1')) self.assertEqual(Regexes.parse_ip('203.0.113.1'), ipaddress.ip_address('203.0.113.1'))
self.assertEqual(Regexes.parse_ip('203.0.113.1:'), ipaddress.ip_address('203.0.113.1')) self.assertEqual(Regexes.parse_ip('203.0.113.1:'), ipaddress.ip_address('203.0.113.1'))
@@ -26,6 +26,17 @@ class SyncPlannerTest extends TestCase
$this->assertSame([], $plan->conflicts); $this->assertSame([], $plan->conflicts);
} }
public function testUnlinkedMailboxWithSeveralIdpUsersIsNeitherLinkedNorOffboarded(): void
{
$users = [self::user('u1', 'a@example.org'), self::user('u2', 'a@example.org', false), self::user('u3', 'a@example.org')];
$plan = $this->plan($users, [new Account('a@example.org', 'A', 1)]);
$this->assertSame([], $plan->links);
$this->assertSame(['Several IdP users have the email a@example.org'], $plan->conflicts);
$this->assertSame([], $plan->missing);
$this->assertSame([], $plan->deactivate);
}
public function testLinkedUserWithNewEmailIsAConflictAndNotCreated(): void public function testLinkedUserWithNewEmailIsAConflictAndNotCreated(): void
{ {
$plan = $this->plan([self::user('u1', 'new@example.org')], [new Account('a@example.org', 'A', 1, 'u1')]); $plan = $this->plan([self::user('u1', 'new@example.org')], [new Account('a@example.org', 'A', 1, 'u1')]);
@@ -132,6 +132,37 @@ class CustomParamsTest extends TestCase
$this->assertSame(['default' => true], CustomParams::allowlist(['bad name' => true], ['default' => true])); $this->assertSame(['default' => true], CustomParams::allowlist(['bad name' => true], ['default' => true]));
} }
public function testBlockedOptionsAreNeverAllowed(): void
{
$allowlist = CustomParams::allowlist(['tests' => false, 'pipemess' => true, 'folder' => true], ['abort' => false]);
$this->assertSame(['folder' => true], $allowlist);
$this->assertSame([], CustomParams::allowlist([], ['abort' => false, 'showpasswords' => false]));
$this->expectException(UserError::class);
CustomParams::toJson([['o' => '--tests', 'v' => '']], $allowlist);
}
public function testCodeExecutingOptionsAreBlocked(): void
{
// imapsync evaluates these values as Perl code (eval) or runs them as commands
foreach (['delete2foldersbutnot', 'delete2foldersonly', 'maxlinelengthcmd', 'oauthrefreshcmd1', 'oauthrefreshcmd2',
'pipemess', 'regexflag', 'regexmess', 'regextrans2', 'skipmess'] as $option) {
$this->assertContains($option, CustomParams::BLOCKED);
}
}
public function testRunnerBlocksTheSameOptions(): void
{
$runner = file_get_contents(__DIR__ . '/../../../../data/Dockerfiles/dovecot/imapsync_runner.py');
$this->assertSame(1, preg_match('/^blocked_params = \{(.*?)\}/ms', $runner, $m));
preg_match_all("/'([a-z0-9]+)'/", $m[1], $options);
$runnerBlocked = $options[1];
$blocked = CustomParams::BLOCKED;
sort($runnerBlocked);
sort($blocked);
$this->assertSame($blocked, $runnerBlocked);
}
public function testOldWhitelistFormatIsIgnored(): void public function testOldWhitelistFormatIsIgnored(): void
{ {
$errorLog = ini_set('error_log', '/dev/null'); $errorLog = ini_set('error_log', '/dev/null');
@@ -219,6 +219,23 @@ class SourceServiceTest extends TestCase
$this->service(FakeContext::admin())->create(['name' => 'a', 'host1' => '192.0.2.10', 'port1' => 143, 'enc1' => 'PLAIN']); $this->service(FakeContext::admin())->create(['name' => 'a', 'host1' => '192.0.2.10', 'port1' => 143, 'enc1' => 'PLAIN']);
} }
public function testCertificateIsCheckedUnlessTurnedOff(): void
{
$stored = [];
$this->repo->expects($this->exactly(3))->method('insert')->willReturnCallback(function (Source $s) use (&$stored): int {
$stored[] = $s->verifyCert;
return 1;
});
$service = $this->service(FakeContext::admin());
$basics = ['name' => 'a', 'host' => 'imap.example.org', 'port' => 143, 'enc' => 'TLS'];
$service->create($basics);
$service->create($basics + ['verify_cert' => '0']);
$service->create($basics + ['verify_cert' => '1']);
$this->assertSame([1, 0, 1], $stored);
$this->assertSame(0, Source::fromRow(['verify_cert' => '0'])->toArray(true, '')['verify_cert']);
}
public function testNameMustBeUniquePerCreator(): void public function testNameMustBeUniquePerCreator(): void
{ {
$this->repo->method('nameExists')->with('<b>', 'admin', null)->willReturn(true); $this->repo->method('nameExists')->with('<b>', 'admin', null)->willReturn(true);
@@ -292,6 +309,36 @@ class SourceServiceTest extends TestCase
$this->expectUserError('access_denied', fn() => $service->update(7, ['name' => 'b'])); $this->expectUserError('access_denied', fn() => $service->update(7, ['name' => 'b']));
} }
public function testServerOnlyChangesWhenAllJobsAreAccessible(): void
{
$this->repo->method('findVisible')->willReturn(self::source(['created_by' => 'u@example.org']));
$this->repo->expects($this->exactly(2))->method('jobMailboxes')
->willReturnOnConsecutiveCalls(['u@example.org', 'other@example.org'], ['u@example.org']);
$this->repo->expects($this->exactly(2))->method('update');
$service = $this->service(FakeContext::user('u@example.org'));
$this->expectUserError(['imapsync_source_used_by_others', 'src'], fn() => $service->update(7, ['host' => 'other.example.org']));
$service->update(7, ['host' => 'new.example.org']);
// Name or description changes keep the server and need no check
$service->update(7, ['description' => 'x']);
}
public function testCertificateCheckOnlyTurnsOffWhenAllJobsAreAccessible(): void
{
$this->repo->method('findVisible')->willReturnOnConsecutiveCalls(
self::source(['created_by' => 'u@example.org']),
self::source(['created_by' => 'u@example.org', 'verify_cert' => 0])
);
$this->repo->expects($this->once())->method('jobMailboxes')->willReturn(['u@example.org', 'other@example.org']);
// Turning it on needs no check and keeps the tokens
$this->repo->expects($this->once())->method('update')
->with($this->callback(fn(Source $s): bool => $s->verifyCert === 1), $this->anything(), false);
$service = $this->service(FakeContext::user('u@example.org'));
$this->expectUserError(['imapsync_source_used_by_others', 'src'], fn() => $service->update(7, ['verify_cert' => '0']));
$service->update(7, ['verify_cert' => '1']);
}
public function testUsersMayNotSwitchToXOAuth2(): void public function testUsersMayNotSwitchToXOAuth2(): void
{ {
$this->repo->method('findVisible')->willReturn(self::source(['created_by' => 'u@example.org'])); $this->repo->method('findVisible')->willReturn(self::source(['created_by' => 'u@example.org']));
@@ -301,6 +301,31 @@ class GroupServiceTest extends TestCase
$this->assertTrue($this->service(self::admin())->canEdit($group)); $this->assertTrue($this->service(self::admin())->canEdit($group));
} }
public function testRemovedGrantsCountUntilDovecotDroppedThem(): void
{
$group = self::stored();
$group->removingImapOwners = ['c@other.org'];
$this->assertFalse($this->service(self::domainAdmin())->canEdit($group));
$this->assertTrue($this->service(self::admin())->canEdit($group));
}
public function testDomainAdminChangesOnlyApplyTemplatesInHisDomains(): void
{
$this->repo->method('findVisible')->willReturn(self::stored([], ['a@example.org' => 'manual', 'c@other.org' => 'manual']));
$this->repo->method('update')->willReturn([]);
$this->repo->method('delete')->willReturn([]);
$effects = $this->createMock(ChangeEffects::class);
$effects->method('apply')->willReturn([]);
// before and after lists of update() may repeat a member, only the set counts
$effects->expects($this->exactly(2))->method('snapshot')
->with($this->callback(fn(array $usernames): bool => array_values(array_unique($usernames)) === ['a@example.org']))->willReturn([]);
$service = new GroupService($this->repo, $this->lookup, $this->templates, $effects, $this->sharing, self::domainAdmin());
$service->update(7, ['active' => 0]);
$service->delete(7);
}
public function testDeleteRemovesTheImapAclsFirst(): void public function testDeleteRemovesTheImapAclsFirst(): void
{ {
$group = self::stored(); $group = self::stored();
@@ -346,13 +371,16 @@ class GroupServiceTest extends TestCase
public function testMembershipsAreVisibleToTheMailboxAndItsManagers(): void public function testMembershipsAreVisibleToTheMailboxAndItsManagers(): void
{ {
$this->lookup->method('membershipsOf')->willReturn([['id' => 7, 'name' => 'Sales']]); $sales = ['id' => 7, 'domain' => 'example.org', 'name' => 'Sales'];
$foreign = ['id' => 8, 'domain' => 'other.org', 'name' => 'Other'];
$this->lookup->method('membershipsOf')->willReturn([$sales, $foreign]);
$this->lookup->method('sendAsOf')->willReturn(['sales@example.org']); $this->lookup->method('sendAsOf')->willReturn(['sales@example.org']);
$this->templates->method('effective')->willReturn(['a@example.org' => 'Staff']); $this->templates->method('effective')->willReturn(['a@example.org' => 'Staff']);
$expected = ['groups' => [['id' => 7, 'name' => 'Sales']], 'template' => 'Staff', 'send_as' => ['sales@example.org']]; $expected = ['groups' => [$sales, $foreign], 'template' => 'Staff', 'send_as' => ['sales@example.org']];
$this->assertSame($expected, $this->service(FakeContext::user('a@example.org', []))->membershipsOf('A@example.org')); $this->assertSame($expected, $this->service(FakeContext::user('a@example.org', []))->membershipsOf('A@example.org'));
$this->assertSame($expected, $this->service(self::domainAdmin())->membershipsOf('a@example.org')); // Groups of other domains stay hidden from a domain admin
$this->assertSame(['groups' => [$sales]] + $expected, $this->service(self::domainAdmin())->membershipsOf('a@example.org'));
$this->assertSame([], $this->service(FakeContext::user('b@example.org', []))->membershipsOf('a@example.org')); $this->assertSame([], $this->service(FakeContext::user('b@example.org', []))->membershipsOf('a@example.org'));
$this->assertSame([], $this->service(self::domainAdmin())->membershipsOf('c@other.org')); $this->assertSame([], $this->service(self::domainAdmin())->membershipsOf('c@other.org'));
} }