From 52ca8f95c21e061c8ce963f7229905a77fd25742 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:13:33 +0200 Subject: [PATCH] [Imapsync] fix custom params --- data/web/api/openapi.yaml | 42 +++--- data/web/inc/functions.syncjob.inc.php | 18 +-- data/web/inc/init_db.inc.php | 4 +- data/web/inc/vars.inc.php | 175 ++++++++++++------------- data/web/js/build/013-mailcow.js | 47 +++++-- data/web/json_api.php | 3 +- data/web/lang/lang.de-de.json | 3 +- data/web/lang/lang.en-gb.json | 3 +- data/web/templates/edit/syncjob.twig | 1 - data/web/templates/modals/mailbox.twig | 1 - data/web/templates/modals/user.twig | 1 - 11 files changed, 161 insertions(+), 137 deletions(-) diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index 076ac3f28..f22ad86a7 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -1520,7 +1520,9 @@ paths: timeout1: "600" timeout2: "600" exclude: "(?i)spam|(?i)junk" - custom_params: '[{"o":"dry","v":""}]' + custom_params: + - {o: dry, v: ""} + - {o: folder, v: INBOX} delete2duplicates: "1" delete1: "1" delete2: "0" @@ -1570,13 +1572,18 @@ paths: type: string custom_params: description: >- - JSON array of allowlisted imapsync options as - option/value pairs, e.g. - `[{"o":"folder","v":"INBOX"},{"o":"dry","v":""}]`. Only - option names from `GET /get/syncjob_options` are accepted; - values may contain any character and are passed as a single - argument. - type: string + Allowlisted imapsync options as option/value pairs. `o` is the + option name (must be one from `GET /get/syncjob_options`); `v` + is the value (empty string for pure flags). Values may contain + any character and are passed to imapsync as a single argument. + type: array + items: + type: object + properties: + o: + type: string + v: + type: string delete2duplicates: description: delete duplicates on destination (--delete2duplicates) type: boolean @@ -4070,13 +4077,18 @@ paths: type: boolean custom_params: description: >- - JSON array of allowlisted imapsync options as - option/value pairs, e.g. - `[{"o":"folder","v":"INBOX"},{"o":"dry","v":""}]`. Only - option names from `GET /get/syncjob_options` are - accepted; values may contain any character and are - passed as a single argument. - type: string + Allowlisted imapsync options as option/value pairs. `o` is + the option name (must be one from `GET /get/syncjob_options`); + `v` is the value (empty string for flags). Values are passed to imapsync as a single + argument. + type: array + items: + type: object + properties: + o: + type: string + v: + type: string delete1: description: Delete from source when completed type: boolean diff --git a/data/web/inc/functions.syncjob.inc.php b/data/web/inc/functions.syncjob.inc.php index ef20d29c1..cd6155a6c 100644 --- a/data/web/inc/functions.syncjob.inc.php +++ b/data/web/inc/functions.syncjob.inc.php @@ -361,15 +361,15 @@ function imapsync_get_settings() { } function imapsync_normalize_custom_params($input) { - $pairs = json_decode((string)$input, true); - if (!is_array($pairs)) return '[]'; - $allow = $GLOBALS['IMAPSYNC_OPTIONS']['whitelist']; + if (!is_array($input)) return '[]'; + $pairs = $input; + $allow = $GLOBALS['IMAPSYNC_OPTIONS']; $out = array(); foreach ($pairs as $p) { if (!is_array($p)) continue; $o = ltrim(strtolower(trim((string)($p['o'] ?? ''))), '-'); if ($o === '') continue; - if (!in_array($o, $allow, true)) return false; // disallowed option -> reject the whole set + if (!array_key_exists($o, $allow)) return false; // disallowed option -> reject the whole set $out[] = array('o' => $o, 'v' => str_replace("\0", '', (string)($p['v'] ?? ''))); } return json_encode($out); @@ -484,7 +484,7 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) { $subfolder2 = $_data['subfolder2']; $user1 = $_data['user1']; $mins_interval = $_data['mins_interval']; - $custom_params = (empty(trim($_data['custom_params']))) ? '' : trim($_data['custom_params']); + $custom_params = $_data['custom_params'] ?? ''; // Resolve and authorize the chosen sync source. Visibility is enforced by syncjob('get', 'source'). $source = syncjob('get', 'source', array('id' => $source_id)); @@ -843,7 +843,8 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) { $subfolder2 = (isset($_data['subfolder2'])) ? $_data['subfolder2'] : $is_now['subfolder2']; $mins_interval = (!empty($_data['mins_interval'])) ? $_data['mins_interval'] : $is_now['mins_interval']; $exclude = (isset($_data['exclude'])) ? $_data['exclude'] : $is_now['exclude']; - $custom_params = (isset($_data['custom_params'])) ? $_data['custom_params'] : $is_now['custom_params']; + $custom_params_provided = isset($_data['custom_params']); + $custom_params = $custom_params_provided ? $_data['custom_params'] : $is_now['custom_params']; $maxage = (isset($_data['maxage']) && $_data['maxage'] != "") ? intval($_data['maxage']) : $is_now['maxage']; $maxbytespersecond = (isset($_data['maxbytespersecond']) && $_data['maxbytespersecond'] != "") ? intval($_data['maxbytespersecond']) : $is_now['maxbytespersecond']; $timeout1 = (isset($_data['timeout1']) && $_data['timeout1'] != "") ? intval($_data['timeout1']) : $is_now['timeout1']; @@ -873,8 +874,9 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) { $password1 = ''; } - // Structured custom params: validate option names against the allowlist, store as JSON pairs - $custom_params = imapsync_normalize_custom_params($custom_params); + if ($custom_params_provided) { + $custom_params = imapsync_normalize_custom_params($custom_params); + } if ($custom_params === false) { $_SESSION['return'][] = array( 'type' => 'danger', diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index 5c0cc8cef..acf5c47b5 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -1535,7 +1535,7 @@ function init_db_schema() // Syncjobs: migrate custom_params from raw imapsync string to structured JSON pairs. // Old values could not contain spaces (they were rejected), so a whitespace split is safe. - $allow = $GLOBALS['IMAPSYNC_OPTIONS']['whitelist']; + $allow = $GLOBALS['IMAPSYNC_OPTIONS']; $cp_upd = $pdo->prepare("UPDATE `imapsync` SET `custom_params` = :cp WHERE `id` = :id"); foreach ($pdo->query("SELECT `id`, `custom_params` FROM `imapsync`")->fetchAll(PDO::FETCH_ASSOC) as $r) { $cp = trim((string)$r['custom_params']); @@ -1548,7 +1548,7 @@ function init_db_schema() $opt = ltrim($tokens[$i], '-'); $val = ''; if (strpos($opt, '=') !== false) { list($opt, $val) = explode('=', $opt, 2); } elseif ($i + 1 < count($tokens) && strpos($tokens[$i + 1], '--') !== 0) { $val = $tokens[++$i]; } - if (!in_array(strtolower($opt), $allow)) continue; // drop options no longer allowed + if (!array_key_exists(strtolower($opt), $allow)) continue; // drop options no longer allowed $pairs[] = array('o' => strtolower($opt), 'v' => $val); } } diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php index 5902768f9..562e4e5ca 100644 --- a/data/web/inc/vars.inc.php +++ b/data/web/inc/vars.inc.php @@ -281,96 +281,89 @@ $RSPAMD_MAPS = array( ); +// Allowed imapsync options for a sync job's custom_params, mapped to whether they take a value: +// true = option takes a value +// false = pure flag, no value +// Anything not listed here is rejected. +// NEVER add (RCE): 'delete2foldersonly', 'delete2foldersbutnot', 'regexflag', 'regexmess', 'pipemess', 'regextrans2', 'maxlinelengthcmd' $IMAPSYNC_OPTIONS = array( - 'whitelist' => array( - 'abort', - 'authmd51', - 'authuser1', - 'debug', - 'debugcontent', - 'debugcrossduplicates', - 'debugflags', - 'debugfolders', - 'debugimap', - 'debugimap1', - 'debugmemory', - 'debugssl', - 'delete1emptyfolders', - 'delete2folders', - 'disarmreadreceipts', - 'domain1', - 'domino1', - 'errorsmax', - 'exchange1', - 'exitwhenover', - 'expunge1', - 'f1f2', - 'filterbuggyflags', - 'folder', - 'folderfirst', - 'folderlast', - 'folderrec', - 'gmail1', - 'idatefromheader', - 'include', - 'inet4', - 'inet6', - 'justconnect', - 'justfolders', - 'justfoldersizes', - 'justlogin', - 'keepalive1', - 'log', - 'maxbytesafter', - 'maxlinelength', - 'maxmessagespersecond', - 'maxsize', - 'maxsleep', - 'minage', - 'minsize', - 'noabletosearch', - 'noabletosearch1', - 'noexpunge1', - 'nofoldersizesatend', - 'noid', - 'nolog', - 'nomixfolders', - 'noresyncflags', - 'nossl1', - 'nosyncacls', - 'notls1', - 'nousecache', - 'office1', - 'prefix1', - 'resyncflags', - 'resynclabels', - 'search', - 'search1', - 'sep1', - 'skipemptyfolders', - 'subfolder1', - 'subscribe', - 'subscribed', - 'syncacls', - 'syncduplicates', - 'syncinternaldates', - 'synclabels', - 'tests', - 'testslive', - 'testslive6', - 'truncmess', - 'usecache', - 'useheader', - 'useuid' - ), - 'blacklist' => array( - 'skipmess', - 'delete2foldersonly', - 'delete2foldersbutnot', - 'regexflag', - 'regexmess', - 'pipemess', - 'regextrans2', - 'maxlinelengthcmd' - ) + 'abort' => false, + 'authmd51' => false, + 'authuser1' => true, + 'debug' => false, + 'debugcontent' => false, + 'debugcrossduplicates' => false, + 'debugflags' => false, + 'debugfolders' => false, + 'debugimap' => false, + 'debugimap1' => false, + 'debugmemory' => false, + 'debugssl' => true, + 'delete1emptyfolders' => false, + 'delete2folders' => false, + 'disarmreadreceipts' => false, + 'domain1' => true, + 'domino1' => false, + 'errorsmax' => true, + 'exchange1' => false, + 'exitwhenover' => true, + 'expunge1' => false, + 'f1f2' => true, + 'filterbuggyflags' => false, + 'folder' => true, + 'folderfirst' => true, + 'folderlast' => true, + 'folderrec' => true, + 'gmail1' => false, + 'idatefromheader' => false, + 'include' => true, + 'inet4' => false, + 'inet6' => false, + 'justconnect' => false, + 'justfolders' => false, + 'justfoldersizes' => false, + 'justlogin' => false, + 'keepalive1' => false, + 'log' => false, + 'maxbytesafter' => true, + 'maxlinelength' => true, + 'maxmessagespersecond' => true, + 'maxsize' => true, + 'maxsleep' => true, + 'minage' => true, + 'minsize' => true, + 'noabletosearch' => false, + 'noabletosearch1' => false, + 'noexpunge1' => false, + 'nofoldersizesatend' => false, + 'noid' => false, + 'nolog' => false, + 'nomixfolders' => false, + 'noresyncflags' => false, + 'nossl1' => false, + 'nosyncacls' => false, + 'notls1' => false, + 'nousecache' => false, + 'office1' => false, + 'prefix1' => true, + 'resyncflags' => false, + 'resynclabels' => false, + 'search' => true, + 'search1' => true, + 'sep1' => true, + 'skipemptyfolders' => false, + 'subfolder1' => true, + 'subscribe' => false, + 'subscribed' => false, + 'syncacls' => false, + 'syncduplicates' => false, + 'syncinternaldates' => false, + 'synclabels' => false, + 'tests' => false, + 'testslive' => false, + 'testslive6' => false, + 'truncmess' => true, + 'usecache' => false, + 'useheader' => true, + 'useuid' => false, ); diff --git a/data/web/js/build/013-mailcow.js b/data/web/js/build/013-mailcow.js index 52ce1075c..b784a8857 100644 --- a/data/web/js/build/013-mailcow.js +++ b/data/web/js/build/013-mailcow.js @@ -520,44 +520,62 @@ function imapsyncSyncSourceOauthToggle($form) { $form.find('.imapsync-source-redirect-uri').val(window.location.origin + '/syncjob-oauth'); } -// Allowlisted imapsync option names for the custom-params selects. Loaded once and cached. +// Allowlisted imapsync options as a map { name: takesValue }. Loaded once and cached. var imapsyncCpOptions = null; function imapsyncCpEnsureOptions(cb) { if (imapsyncCpOptions !== null) { cb(); return; } $.get("/api/v1/get/syncjob_options", function(opts) { - imapsyncCpOptions = Array.isArray(opts) ? opts : []; + imapsyncCpOptions = (opts && typeof opts === 'object') ? opts : {}; cb(); }); } -// Append one row to an editor; option is a bootstrap-select (not free-text), value is arbitrary. +// A imapsync flag option takes no value +function imapsyncCpTakesValue(name) { + return !(imapsyncCpOptions && imapsyncCpOptions[name] === false); +} + +// Enable/disable/require a row's value field based on the selected option. +function imapsyncCpApplyValueState($row) { + var name = $row.find('.imapsync-cp-opt').val() || ''; + var isFlag = (name !== '') && !imapsyncCpTakesValue(name); + var needsValue = (name !== '') && !isFlag; + var $val = $row.find('.imapsync-cp-val'); + $val.prop('disabled', isFlag); + if (isFlag) $val.val(''); + $val.prop('required', needsValue); + $val.attr('placeholder', isFlag + ? ((lang.syncjobs && lang.syncjobs.custom_param_no_value) || 'no value') + : ((lang.syncjobs && lang.syncjobs.custom_param_value) || 'value')); +} + +// Append one row to an editor; option is a native ' + + '' + '' + '' + '' ); - var $sel = $row.find('.imapsync-cp-opt') - .attr('title', (lang.syncjobs && lang.syncjobs.custom_param_option) || 'option'); - (imapsyncCpOptions || []).forEach(function(name) { + var $sel = $row.find('.imapsync-cp-opt'); + $sel.append($('').attr('value', '').text('— ' + ((lang.syncjobs && lang.syncjobs.custom_param_option) || 'option') + ' —')); + Object.keys(imapsyncCpOptions || {}).forEach(function(name) { $sel.append($('').attr('value', name).text(name)); }); // keep a stored value that is no longer allowlisted visible instead of silently dropping it - if (o && (imapsyncCpOptions || []).indexOf(o) === -1) { + if (o && !(imapsyncCpOptions && (o in imapsyncCpOptions))) { $sel.append($('').attr('value', o).text(o)); } $sel.val(o || ''); - $row.find('.imapsync-cp-val').attr('placeholder', (lang.syncjobs && lang.syncjobs.custom_param_value) || 'value').val(v || ''); + $row.find('.imapsync-cp-val').val(v || ''); $editor.find('.imapsync-cp-rows').append($row); - $sel.selectpicker(); + imapsyncCpApplyValueState($row); } // Build the rows of an editor from its hidden JSON value. function imapsyncCpBuild($editor) { var $hidden = $editor.siblings('.imapsync-cp-value'); - $editor.find('.imapsync-cp-opt').selectpicker('destroy'); $editor.find('.imapsync-cp-rows').empty(); var pairs = []; try { pairs = JSON.parse($hidden.val() || '[]'); } catch (e) { pairs = []; } @@ -581,6 +599,7 @@ function imapsyncCpSerialize($editor) { $(document).ready(function() { // Custom-params editor: keep the hidden JSON in sync with the rows $(document).on('change', '.imapsync-cp-opt', function() { + imapsyncCpApplyValueState($(this).closest('.imapsync-cp-row')); imapsyncCpSerialize($(this).closest('.imapsync-cp-editor')); }); $(document).on('input', '.imapsync-cp-val', function() { @@ -592,7 +611,6 @@ $(document).ready(function() { }); $(document).on('click', '.imapsync-cp-remove', function() { var $editor = $(this).closest('.imapsync-cp-editor'); - $(this).closest('.imapsync-cp-row').find('.imapsync-cp-opt').selectpicker('destroy'); $(this).closest('.imapsync-cp-row').remove(); imapsyncCpSerialize($editor); }); @@ -609,11 +627,12 @@ $(document).ready(function() { var $form = $sel.closest('form'); var isOauth = ($opt.data('auth-type') === 'XOAUTH2'); var isAuthCode = isOauth && ($opt.data('oauth-flow') === 'authorization_code'); + var isEdit = ($form.data('id') === 'editsyncjob'); $form.find('.password1-row').toggle(!isOauth); - $form.find('input[name="password1"]').prop('required', !isOauth); + $form.find('input[name="password1"]').prop('required', !isOauth && !isEdit); $form.find('.imapsync-oauth-connect-row').toggle(isAuthCode); $form.find('.imapsync-user1-row').toggle(!isAuthCode); - $form.find('input[name="user1"]').prop('required', !isAuthCode); + $form.find('input[name="user1"]').prop('required', !isAuthCode && !isEdit); }); // Source editor: OAuth block + authorization_code fields diff --git a/data/web/json_api.php b/data/web/json_api.php index c711e34bd..fdf2d13c3 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -1050,8 +1050,7 @@ if (isset($_GET['query'])) { process_get_return($data); break; case "syncjob_options": - // allowlisted imapsync option names, for the custom-params datalist in the UI - process_get_return($GLOBALS['IMAPSYNC_OPTIONS']['whitelist']); + process_get_return($GLOBALS['IMAPSYNC_OPTIONS']); break; case "syncjob_source": switch ($object) { diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index 1befc2bbe..f9529bbdd 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -1297,7 +1297,8 @@ "custom_params": "Eigene Parameter", "custom_params_hint": "Pro Zeile eine imapsync-Option. Es werden nur erlaubte Optionen akzeptiert; der Wert darf beliebige Zeichen enthalten (Leerzeichen, Regex, …) und wird als ein einzelnes Argument übergeben.", "custom_param_option": "Option", - "custom_param_value": "Wert (optional)", + "custom_param_value": "Wert", + "custom_param_no_value": "kein Wert nötig", "custom_param_add": "Parameter hinzufügen", "order_id": "Position", "order_id_hint": "Kleinere Position läuft zuerst; Ändern sortiert die globale Warteschlange um.", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index f961dc160..b002d6cba 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -1304,7 +1304,8 @@ "custom_params": "Custom parameters", "custom_params_hint": "Add one imapsync option per row. Only allowlisted options are accepted; the value may contain any character (spaces, regex, …) and is passed as a single argument.", "custom_param_option": "Option", - "custom_param_value": "Value (optional)", + "custom_param_value": "Value", + "custom_param_no_value": "no value needed", "custom_param_add": "Add parameter", "order_id": "Position", "order_id_hint": "Lower position runs first; setting it re-sorts the global queue.", diff --git a/data/web/templates/edit/syncjob.twig b/data/web/templates/edit/syncjob.twig index 6e190f8ac..3375a5596 100644 --- a/data/web/templates/edit/syncjob.twig +++ b/data/web/templates/edit/syncjob.twig @@ -96,7 +96,6 @@ - {{ lang.syncjobs.custom_params_hint }}
diff --git a/data/web/templates/modals/mailbox.twig b/data/web/templates/modals/mailbox.twig index 0865cf963..a1d77d69d 100644 --- a/data/web/templates/modals/mailbox.twig +++ b/data/web/templates/modals/mailbox.twig @@ -1034,7 +1034,6 @@
- {{ lang.syncjobs.custom_params_hint }}
diff --git a/data/web/templates/modals/user.twig b/data/web/templates/modals/user.twig index 0b0d781cd..30e26027d 100644 --- a/data/web/templates/modals/user.twig +++ b/data/web/templates/modals/user.twig @@ -118,7 +118,6 @@
- {{ lang.syncjobs.custom_params_hint }}