diff --git a/data/Dockerfiles/dovecot/imapsync_runner.py b/data/Dockerfiles/dovecot/imapsync_runner.py index 09fa1eda6..53dca5ea6 100755 --- a/data/Dockerfiles/dovecot/imapsync_runner.py +++ b/data/Dockerfiles/dovecot/imapsync_runner.py @@ -1,10 +1,12 @@ #!/usr/bin/python3 import fcntl +import ipaddress import json import os import re import signal +import socket import subprocess import sys import tempfile @@ -18,6 +20,15 @@ max_output = 16 * 1024 * 1024 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) 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', '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 token_sync_interval = 60 @@ -106,6 +117,20 @@ def defer_job(job_id, text, exit_status): (text, exit_status, 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 + def oauth_token(j): if j['oauth_flow'] == 'authorization_code': return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {} @@ -150,6 +175,11 @@ def prepare_job(j, global_bw, files): f.write(content) 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' oauth = None if auth_type == 'XOAUTH2': @@ -174,7 +204,11 @@ def prepare_job(j, global_bw, files): except ValueError: pairs = [] 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 value = '' if p.get('v') is None else str(p['v']) custom_params.append('--' + p['o'] if value == '' else '--' + p['o'] + '=' + value) @@ -205,7 +239,8 @@ def prepare_job(j, global_bw, files): cmd.append(opt) enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1']) if enc1: - cmd.append(enc1) + # Check the certificate of the source (imapsync does not by default), custom sslargs1 can turn it off + cmd += [enc1, '--sslargs1', 'SSL_verify_mode=1'] 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')] if j['dry'] == 1: @@ -216,8 +251,10 @@ def prepare_job(j, global_bw, files): # Worker: imapsync with output to a file, no inherited fds (DB socket, lock) 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: - 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 def reap_job(job): diff --git a/data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php b/data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php index 4a900e855..7f31d67d7 100644 --- a/data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php +++ b/data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php @@ -19,6 +19,14 @@ class CustomParams { private const MAX_PAIRS = 100; 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', 'tests', 'testsdebug', 'testslive', 'testslive6', 'testsunit', 'tmpdir']; private static bool $warnedOldFormat = false; @@ -57,7 +65,7 @@ class CustomParams /** * 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 { @@ -73,7 +81,7 @@ class CustomParams 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)"); } - 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 */ diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php index 451704565..3892ae315 100644 --- a/data/web/inc/vars.inc.php +++ b/data/web/inc/vars.inc.php @@ -296,8 +296,8 @@ $RSPAMD_MAPS = array( // 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 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( - 'abort' => false, 'authmd51' => false, 'authuser1' => true, 'debug' => false, @@ -373,9 +373,6 @@ $IMAPSYNC_OPTIONS_DEFAULT = array( 'syncduplicates' => false, 'syncinternaldates' => false, 'synclabels' => false, - 'tests' => false, - 'testslive' => false, - 'testslive6' => false, 'truncmess' => true, 'usecache' => false, 'useheader' => true, diff --git a/docker-compose.yml b/docker-compose.yml index c77c7baaa..81bdd7468 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -259,7 +259,7 @@ services: - sogo dovecot-mailcow: - image: ghcr.io/mailcow/dovecot:2.3.21.1-3 + image: ghcr.io/mailcow/dovecot:2.3.21.1-4 depends_on: - mysql-mailcow - netfilter-mailcow @@ -295,6 +295,7 @@ services: - MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} - MAILCOW_PASS_SCHEME=${MAILCOW_PASS_SCHEME:-BLF-CRYPT} - 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} - MAILDIR_GC_TIME=${MAILDIR_GC_TIME:-7200} - ACL_ANYONE=${ACL_ANYONE:-disallow} diff --git a/tests/php/Imapsync/Model/CustomParamsTest.php b/tests/php/Imapsync/Model/CustomParamsTest.php index 924db327b..9ca43eace 100644 --- a/tests/php/Imapsync/Model/CustomParamsTest.php +++ b/tests/php/Imapsync/Model/CustomParamsTest.php @@ -132,6 +132,16 @@ class CustomParamsTest extends TestCase $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 testOldWhitelistFormatIsIgnored(): void { $errorLog = ini_set('error_log', '/dev/null');