mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-05 07:58:20 +00:00
[Dovecot][Web] imapsync: restrict custom parameters and source hosts, verify certificates
This commit is contained in:
5 files changed
+63
-10
No files matched your search
@@ -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 */
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user