[Dovecot][Web] imapsync: restrict custom parameters and source hosts, verify certificates

This commit is contained in:
FreddleSpl0it committed 2026-11-02 09:49:03 +01:00
1 parent bab03ed88b
commit a1cbd51d70
5 files changed
+63 -10

No files matched your search

+40 -3
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', '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
@@ -106,6 +117,20 @@ 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
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 +175,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 +204,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 +239,8 @@ 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) # 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 += ['--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 +251,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):
@@ -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', '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 */
+1 -4
View File
@@ -296,8 +296,8 @@ $RSPAMD_MAPS = array(
// 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 +373,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,
+2 -1
View File
@@ -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}
@@ -132,6 +132,16 @@ 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 testOldWhitelistFormatIsIgnored(): void public function testOldWhitelistFormatIsIgnored(): void
{ {
$errorLog = ini_set('error_log', '/dev/null'); $errorLog = ini_set('error_log', '/dev/null');