[Imapsync] fix custom params

This commit is contained in:
FreddleSpl0it
2026-07-21 17:13:33 +02:00
parent 6758999cf3
commit 52ca8f95c2
11 changed files with 161 additions and 137 deletions
+27 -15
View File
@@ -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
+10 -8
View File
@@ -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',
+2 -2
View File
@@ -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);
}
}
+84 -91
View File
@@ -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,
);
+33 -14
View File
@@ -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 <select> (not free-text), value is arbitrary.
function imapsyncCpAddRow($editor, o, v) {
var $row = $(
'<div class="input-group mb-2 imapsync-cp-row">' +
'<select class="form-control imapsync-cp-opt" data-width="35%"></select>' +
'<select class="form-control imapsync-cp-opt" style="flex:0 0 35%"></select>' +
'<input type="text" class="form-control imapsync-cp-val">' +
'<button type="button" class="btn btn-secondary imapsync-cp-remove"><i class="bi bi-trash"></i></button>' +
'</div>'
);
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($('<option></option>').attr('value', '').text('— ' + ((lang.syncjobs && lang.syncjobs.custom_param_option) || 'option') + ' —'));
Object.keys(imapsyncCpOptions || {}).forEach(function(name) {
$sel.append($('<option></option>').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($('<option></option>').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
+1 -2
View File
@@ -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) {
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
-1
View File
@@ -96,7 +96,6 @@
<button type="button" class="btn btn-sm btn-secondary imapsync-cp-add"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.custom_param_add }}</button>
</div>
<input type="hidden" name="custom_params" class="imapsync-cp-value" value="{{ result.custom_params }}">
<small class="text-muted d-block mt-1">{{ lang.syncjobs.custom_params_hint }}</small>
</div>
</div>
<div class="row mb-2">
-1
View File
@@ -1034,7 +1034,6 @@
<button type="button" class="btn btn-sm btn-secondary imapsync-cp-add"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.custom_param_add }}</button>
</div>
<input type="hidden" name="custom_params" class="imapsync-cp-value" value="">
<small class="text-muted d-block mt-1">{{ lang.syncjobs.custom_params_hint }}</small>
</div>
</div>
<div class="row mb-2">
-1
View File
@@ -118,7 +118,6 @@
<button type="button" class="btn btn-sm btn-secondary imapsync-cp-add"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.custom_param_add }}</button>
</div>
<input type="hidden" name="custom_params" class="imapsync-cp-value" value="">
<small class="text-muted d-block mt-1">{{ lang.syncjobs.custom_params_hint }}</small>
</div>
</div>
<div class="row mb-2">