[exim-cvs] Taint: reject tainted list-separator change

Pàgina inicial
Delete this message
Reply to this message
Autor: Exim Git Commits Mailing List
Data:  
A: exim-cvs
Assumpte: [exim-cvs] Taint: reject tainted list-separator change
Gitweb: https://git.exim.org/exim.git/commitdiff/9d66ba85a9646c0b63c54acf69e186f0e785855d
Commit:     9d66ba85a9646c0b63c54acf69e186f0e785855d
Parent:     347d8633fd5ff7e85fd0f27444d6d1260c2212de
Author:     Jeremy Harris <jgh146exb@???>
AuthorDate: Tue Nov 19 11:42:40 2024 +0000
Committer:  Jeremy Harris <jgh146exb@???>
CommitDate: Tue Nov 19 11:42:40 2024 +0000

    Taint: reject tainted list-separator change
---
 doc/doc-docbook/spec.xfpt    | 110 +++++++++++++++++++++++++++++++------------
 doc/doc-txt/ChangeLog        |   2 +
 src/src/acl.c                |  57 ++++++++++------------
 src/src/daemon.c             |  10 +++-
 src/src/dnsbl.c              |  31 ++++++++----
 src/src/expand.c             | 100 +++++++++++++++++++++++++++------------
 src/src/functions.h          |   3 +-
 src/src/match.c              |  43 +++++++++++++++--
 src/src/readconf.c           |   6 +--
 src/src/string.c             |  10 ++--
 test/confs/2610              |   1 +
 test/log/2610                |   4 +-
 test/paniclog/2610           |   2 +-
 test/scripts/0000-Basic/0002 |   5 +-
 test/stderr/0002             |  54 +++++++++++++++++++--
 test/stderr/0023             |  12 ++---
 test/stderr/0149             |   8 ++--
 test/stderr/0275             |  10 ++--
 test/stderr/0277             |  20 ++++----
 test/stderr/0278             |  15 +++---
 test/stderr/0279             |   5 +-
 test/stderr/0475             |   8 ++--
 test/stderr/1000             |  12 ++---
 test/stderr/1002             |  16 +++----
 test/stderr/2610             |  48 ++++++++++---------
 test/stderr/2620             |  20 ++++----
 test/stdout/0002             |   3 +-
 27 files changed, 412 insertions(+), 203 deletions(-)

diff --git a/doc/doc-docbook/spec.xfpt b/doc/doc-docbook/spec.xfpt
index 1f90d6c86..ae30cb886 100644
--- a/doc/doc-docbook/spec.xfpt
+++ b/doc/doc-docbook/spec.xfpt
@@ -8441,10 +8441,18 @@ type of match and is given below as the &*value*& information.
 
 .section "Expansion of lists" "SECTlistexpand"
 .cindex "expansion" "of lists"
-Each list is expanded as a single string before it is used.
+.new
+Each list, after any leading change-of-separator specification
+(see &<<SECTlistsepchange>>&) is expanded as a single string,
 .cindex "tainted data" tracking
-&*Note*&: As a result, if any componend was tainted then the
-entire result string becomes tainted.
+&*Note*&: As a result, if any component was tainted then the
+entire expansion result string becomes tainted.
+
+Splitting out a leading explicit change-of-separator permits
+one being safely used on a list that has tainted components
+while still detecting the use of a tainted setting.
+The latter is not permitted.
+.wen
 
 &'Exception: the router headers_remove option, where list-item
 splitting is done before string-expansion.'&
@@ -10096,11 +10104,15 @@ leading and trailing quotes are removed from the returned value.
 .cindex "list" "selecting by condition"
 .cindex "expansion" "selecting from list by condition"
 .vindex "&$item$&"
-After expansion, <&'string'&> is interpreted as a list, colon-separated by
-default, but the separator can be changed in the usual way (&<<SECTlistsepchange>>&).
-For each item
-in this list, its value is placed in &$item$&, and then the condition is
-evaluated.
+.new
+<&'string1'&> first has the part after any change-of-list-separator
+(see &<<SECTlistsepchange>>&) expanded,
+then the whole is taken as a list.
+.wen
+The default separator for the list is a colon.
+
+For each item in this list,
+its value is placed in &$item$&, and then the condition is evaluated.
 Any modification of &$value$& by this evaluation is discarded.
 If the condition is true, &$item$& is added to the output as an
 item in a new list; if the condition is false, the item is discarded. The
@@ -10364,8 +10376,12 @@ The <&'number'&> argument must consist entirely of decimal digits,
 apart from an optional leading minus,
 and leading and trailing white space (which is ignored).
 
-After expansion, <&'string1'&> is interpreted as a list, colon-separated by
-default, but the separator can be changed in the usual way (&<<SECTlistsepchange>>&).
+.new
+The <&'string1'&> argument, after any leading change-of-separator
+(see &<<SECTlistsepchange>>&),
+is expanded and the whole forms the list.
+.wen
+By default, the list separator is a colon.
 
 The first field of the list is numbered one.
 If the number is negative, the fields are
@@ -10465,10 +10481,15 @@ ${lookup nisplus {[name=$local_part],passwd.org_dir:gcos} \
 .vitem &*${map{*&<&'string1'&>&*}{*&<&'string2'&>&*}}*&
 .cindex "expansion" "list creation"
 .vindex "&$item$&"
-After expansion, <&'string1'&> is interpreted as a list, colon-separated by
-default, but the separator can be changed in the usual way (&<<SECTlistsepchange>>&).
-For each item
-in this list, its value is place in &$item$&, and then <&'string2'&> is
+.new
+<&'string1'&> first has the part after any change-of-list-separator
+(see &<<SECTlistsepchange>>&) expanded,
+then the whole is taken as a list.
+.wen
+The default separator for the list is a colon.
+
+For each item in this list,
+its value is place in &$item$&, and then <&'string2'&> is
 expanded and added to the output as an item in a new list. The separator used
 for the output list is the same as the one used for the input, but a separator
 setting is not included in the output. For example:
@@ -10688,9 +10709,15 @@ locks out the use of this expansion item in filter files.
 .cindex "list" "reducing to a scalar"
 .vindex "&$value$&"
 .vindex "&$item$&"
-This operation reduces a list to a single, scalar string. After expansion,
-<&'string1'&> is interpreted as a list, colon-separated by default, but the
-separator can be changed in the usual way (&<<SECTlistsepchange>>&).
+This operation reduces a list to a single, scalar string.
+
+.new
+<&'string1'&> first has the part after any change-of-list-separator
+(see &<<SECTlistsepchange>>&) expanded,
+then the whole is taken as a list.
+.wen
+The default separator for the list is a colon.
+
 Then <&'string2'&> is expanded and
 assigned to the &$value$& variable. After this, each item in the <&'string1'&>
 list is assigned to &$item$&, in turn, and <&'string3'&> is expanded for each of
@@ -10847,8 +10874,13 @@ rather than any Unicode-aware character handling.
 .cindex sorting "a list"
 .cindex list sorting
 .cindex expansion "list sorting"
-After expansion, <&'string'&> is interpreted as a list, colon-separated by
-default, but the separator can be changed in the usual way (&<<SECTlistsepchange>>&).
+.new
+<&'string'&> first has the part after any change-of-list-separator
+(see &<<SECTlistsepchange>>&) expanded,
+then the whole is taken as a list.
+.wen
+The default separator for the list is a colon.
+
 The <&'comparator'&> argument is interpreted as the operator
 of a two-argument expansion condition.
 The numeric operators plus ge, gt, le, lt (and ~i variants) are supported.
@@ -11304,7 +11336,8 @@ All measurement is done in bytes and is not UTF-8 aware.
 .cindex "list" "item count"
 .cindex "list" "count of items"
 .cindex "&%listcount%& expansion item"
-The string is interpreted as a list and the number of items is returned.
+The part of the string after any leading change-of-separator is expanded,
+then the whole is interpreted as a list and the number of items is returned.
 
 
 .vitem &*${listnamed:*&<&'name'&>&*}*&&~and&~&*${listnamed_*&<&'type'&>&*:*&<&'name'&>&*}*&
@@ -11925,9 +11958,14 @@ attempt. It is false during any subsequent delivery attempts.
 .cindex "expansion" "&*forall*& condition"
 .cindex "expansion" "&*forany*& condition"
 .vindex "&$item$&"
-These conditions iterate over a list. The first argument is expanded to form
-the list. By default, the list separator is a colon, but it can be changed by
-the normal method (&<<SECTlistsepchange>>&).
+These conditions iterate over a list.
+.new
+The first argument, after any leading change-of-separator
+(see &<<SECTlistsepchange>>&),
+is expanded and the whole forms the list.
+.wen
+By default, the list separator is a colon.
+
 The second argument is interpreted as a condition that is to
 be applied to each item in the list in turn. During the interpretation of the
 condition, the current list item is placed in a variable called &$item$&.
@@ -12004,9 +12042,14 @@ SRS decode.  See SECT &<<SECTSRS>>& for details.
        &*inlisti&~{*&<&'subject'&>&*}{*&<&'list'&>&*}*&
 .cindex "string" "comparison"
 .cindex "list" "iterative conditions"
-Both strings are expanded; the second string is treated as a list of simple
-strings; if the first string is a member of the second, then the condition
-is true.
+The <&'subject'&> string is expanded.
+.new
+The <&'list'&> first has any change-of-list-separator
++(see &<<SECTlistsepchange>>&) retained verbatim,
++then the remainder is expanded.
++.wen
+The whole is treated as a list of simple strings;
+if the subject string is a member of that list, then the condition is true.
 For the case-independent &%inlisti%& condition, case is defined per the system C locale.
 
 These are simpler to use versions of the more powerful &*forany*& condition.
@@ -12199,6 +12242,10 @@ just as easy to use the fact that a lookup is itself a condition, and write:
 
 Note that <&'string2'&> is not itself subject to string expansion, unless
 Exim was built with the EXPAND_LISTMATCH_RHS option.
+.new
+For the latter case, only the part after any leading
+change-of-separator specification is expanded.
+.wen
 
 Consult section &<<SECThoslispatip>>& for further details of these patterns.
 
@@ -12227,9 +12274,10 @@ ${if match_domain{$domain}{+local_domains}{...
 .endd
 .cindex "&`+caseful`&"
 For address lists, the matching starts off caselessly, but the &`+caseful`&
-item can be used, as in all address lists, to cause subsequent items to
-have their local parts matched casefully. Domains are always matched
-caselessly.
+item can be used, as in all address lists, to cause subsequent items
+(including those of referenced named lists)
+to have their local parts matched casefully.
+Domains are always matched caselessly.
 
 The variable &$value$& will be set for a successful match and can be
 used in the success clause of an &%if%& expansion item using the condition.
@@ -12244,6 +12292,10 @@ Any previous &$value$& is restored after the if.
 
 Note that <&'string2'&> is not itself subject to string expansion, unless
 Exim was built with the EXPAND_LISTMATCH_RHS option.
+.new
+For the latter case, only the part after any leading
+change-of-separator specification is expanded.
+.wen
 
 &*Note*&: Host lists are &'not'& supported in this way. This is because
 hosts have two identities: a name and an IP address, and it is not clear
diff --git a/doc/doc-txt/ChangeLog b/doc/doc-txt/ChangeLog
index bd4fd1921..c9f7a4375 100644
--- a/doc/doc-txt/ChangeLog
+++ b/doc/doc-txt/ChangeLog
@@ -67,6 +67,8 @@ JH/14 Bug 3116: Fix crash in dkim signing.  On kernels supporting immutable
       memory segments, a write was done into one when a constant string was
       configured for a transport's dkim private key.
 
+JH/15 Disallow tainted change-of-separator on lists
+
 Exim version 4.98
 -----------------
 
diff --git a/src/src/acl.c b/src/src/acl.c
index 88d09479c..3f35a4946 100644
--- a/src/src/acl.c
+++ b/src/src/acl.c
@@ -241,7 +241,7 @@ static condition_def conditions[] = {
 
   /* Explicit key lookups can be made in non-smtp ACLs so pass
   always and check in the verify processing itself. */
-  [ACLC_DNSLISTS] =        { US"dnslists",        ACD_EXP,
+  [ACLC_DNSLISTS] =        { US"dnslists",        0,
                   FORBIDDEN(0) },
 
   [ACLC_DOMAINS] =        { US"domains",        0,
@@ -786,7 +786,7 @@ Returns:      offset in list, or -1 if not found
 */
 
 static int
-acl_checkcondition(uschar * name, condition_def * list, int end)
+acl_findcondition(uschar * name, condition_def * list, int end)
 {
 for (int start = 0; start < end; )
   {
@@ -994,7 +994,7 @@ while ((s = (*func)()))
 
   /* Handle a condition or modifier. */
 
-  if ((c = acl_checkcondition(name, conditions, nelem(conditions))) < 0)
+  if ((c = acl_findcondition(name, conditions, nelem(conditions))) < 0)
     {
     *error = string_sprintf("unknown ACL condition/modifier in \"%s\"",
       saveline);
@@ -1025,7 +1025,7 @@ while ((s = (*func)()))
   if (conditions[c].flags & ACD_LOAD)
     {                /* a loadable module supports this condition */
     condition_module * cm;
-    uschar * s = NULL;
+    uschar * t = NULL;
 
     /* Over the list of modules we support, check the list of ACL conditions
     each supports.  This assumes no duplicates. */
@@ -1043,10 +1043,10 @@ while ((s = (*func)()))
       return NULL;
       }
     if (  !cm->info                /* module not loaded */
-       && !(cm->info = misc_mod_find(cm->mod_name, &s)))
+       && !(cm->info = misc_mod_find(cm->mod_name, &t)))
       {
       *error = string_sprintf("ACL error: failed to find module for '%s': %s",
-                  conditions[c].name, s);
+                  conditions[c].name, t);
       return NULL;
       }
     }
@@ -3350,12 +3350,11 @@ Returns:       OK        - all conditions are met
 */
 
 static int
-acl_check_condition(int verb, acl_condition_block *cb, int where,
-  address_item *addr, int level, BOOL *epp, uschar **user_msgptr,
-  uschar **log_msgptr, int *basic_errno)
+acl_check_condition(int verb, acl_condition_block * cb, int where,
+  address_item * addr, int level, BOOL * epp, uschar ** user_msgptr,
+  uschar ** log_msgptr, int * basic_errno)
 {
-uschar * user_message = NULL;
-uschar * log_message = NULL;
+uschar * user_message = NULL, * log_message = NULL;
 int rc = OK;
 
 for (; cb; cb = cb->next)
@@ -3364,30 +3363,24 @@ for (; cb; cb = cb->next)
   int control_type;
   BOOL textonly = FALSE;
 
-  /* The message and log_message items set up messages to be used in
-  case of rejection. They are expanded later. */
-
-  if (cb->type == ACLC_MESSAGE)
+  switch (cb->type)
     {
-    HDEBUG(D_acl) debug_printf_indent("  message: %s\n", cb->arg);
-    user_message = cb->arg;
-    continue;
-    }
+    /* The message and log_message items set up messages to be used in
+    case of rejection. They are expanded later. */
 
-  if (cb->type == ACLC_LOG_MESSAGE)
-    {
-    HDEBUG(D_acl) debug_printf_indent("l_message: %s\n", cb->arg);
-    log_message = cb->arg;
-    continue;
-    }
+    case ACLC_MESSAGE:
+      HDEBUG(D_acl) debug_printf_indent("  message: %s\n", cb->arg);
+      user_message = cb->arg;    continue;
 
-  /* The endpass "condition" just sets a flag to show it occurred. This is
-  checked at compile time to be on an "accept" or "discard" item. */
+    case ACLC_LOG_MESSAGE:
+      HDEBUG(D_acl) debug_printf_indent("l_message: %s\n", cb->arg);
+      log_message = cb->arg;    continue;
 
-  if (cb->type == ACLC_ENDPASS)
-    {
-    *epp = TRUE;
-    continue;
+    /* The endpass "condition" just sets a flag to show it occurred. This is
+    checked at compile time to be on an "accept" or "discard" item. */
+
+    case ACLC_ENDPASS:
+      *epp = TRUE;        continue;
     }
 
   /* For other conditions and modifiers, the argument is expanded now for some
@@ -4032,7 +4025,7 @@ for (; cb; cb = cb->next)
 #endif
 
     case ACLC_DNSLISTS:
-      rc = verify_check_dnsbl(where, &arg, log_msgptr);
+      rc = verify_check_dnsbl(where, arg, log_msgptr);
       break;
 
     case ACLC_DOMAINS:
diff --git a/src/src/daemon.c b/src/src/daemon.c
index 4272dae7a..0c6ed4da0 100644
--- a/src/src/daemon.c
+++ b/src/src/daemon.c
@@ -1893,7 +1893,13 @@ if (f.daemon_listen && !f.inetd_wait_mode)
 
     if (!override_pid_file_path) write_pid = FALSE;
 
-    list = override_local_interfaces;
+    /* specifically permit change-of-separator for admins (who should be
+    the only people getting here, but we may as well be careful) */
+
+    list = f.admin_user
+      ? string_copy_taint(override_local_interfaces, GET_UNTAINTED)
+      : override_local_interfaces;
+
     for (int sep = 0; s = string_nextinlist(&list, &sep, NULL, 0); )
       {
       uschar joinstr[4];
@@ -1940,7 +1946,7 @@ if (f.daemon_listen && !f.inetd_wait_mode)
       {
       uschar * end;
       default_smtp_port[pct] = Ustrtol(s, &end, 0);
-      if (end != s + Ustrlen(s))
+      if (*end)
         log_write(0, LOG_PANIC_DIE|LOG_CONFIG, "invalid SMTP port: %s", s);
       }
     else
diff --git a/src/src/dnsbl.c b/src/src/dnsbl.c
index 1172d6183..8901b11ff 100644
--- a/src/src/dnsbl.c
+++ b/src/src/dnsbl.c
@@ -452,24 +452,24 @@ Note: an address for testing DUL is 192.203.178.4
 Note: a domain for testing RFCI is example.tld.dsn.rfc-ignorant.org
 
 Arguments:
-  where        the acl type
-  listptr      the domain/address/data list
-  log_msgptr   log message on error
+  where        the acl type
+  list        the domain/address/data list
+  log_msgptr    log message on error
 
 Returns:    OK      successful lookup (i.e. the address is on the list), or
                       lookup deferred after +include_unknown
             FAIL    name not found, or no data found for the given type, or
                       lookup deferred after +exclude_unknown (default)
             DEFER   lookup failure, if +defer_unknown was set
+        ERROR   error during expansion
 */
 
 int
-verify_check_dnsbl(int where, const uschar ** listptr, uschar ** log_msgptr)
+verify_check_dnsbl(int where, const uschar * list, uschar ** log_msgptr)
 {
-int sep = 0;
-int defer_return = FAIL;
-const uschar *list = *listptr;
-uschar *domain;
+int sep, defer_return = FAIL;
+uschar * domain;
+const uschar * s = list;
 uschar revadd[128];        /* Long enough for IPv6 address */
 
 /* Indicate that the inverted IP address is not yet set up */
@@ -480,6 +480,21 @@ revadd[0] = 0;
 
 dns_init(FALSE, FALSE, FALSE);    /*XXX dnssec? */
 
+/* Expand the list string.  This used to be done by the caller
+but we want to first strip any change-of-list-separator */
+
+sep = matchlist_parse_sep(&s);
+
+if (!(s= expand_cstring(s)))
+  {
+  if (f.expand_string_forcedfail) return OK;
+  *log_msgptr = string_sprintf("failed to expand ACL string \"%s\": %s",
+    list, expand_string_message);
+  return f.search_find_defer ? DEFER : ERROR;
+  }
+HDEBUG(D_acl) if (s != list) debug_printf_indent("expanded list: %s\n", s);
+list = s;
+
 /* Loop through all the domains supplied, until something matches */
 
 while ((domain = string_nextinlist(&list, &sep, NULL, 0)))
diff --git a/src/src/expand.c b/src/src/expand.c
index e4224dbb1..052c059e8 100644
--- a/src/src/expand.c
+++ b/src/src/expand.c
@@ -1331,18 +1331,15 @@ return fieldtext;
 
 
 static uschar *
-expand_getlistele(int field, const uschar * list)
+expand_getlistele(int field, const uschar * list, int sep)
 {
 const uschar * tlist = list;
-int sep = 0;
+int sep_l = sep;
 /* Tainted mem for the throwaway element copies */
 uschar * dummy = store_get(2, GET_TAINTED);
 
 if (field < 0)
-  {
-  for (field++; string_nextinlist(&tlist, &sep, dummy, 1); ) field++;
-  sep = 0;
-  }
+  for (field++; string_nextinlist(&tlist, &sep_l, dummy, 1); ) field++;
 if (field == 0) return NULL;
 while (--field > 0 && (string_nextinlist(&list, &sep, dummy, 1))) ;
 return string_nextinlist(&list, &sep, NULL, 0);
@@ -2947,14 +2944,12 @@ switch(cond_type = identify_operator(&s, &opname))
   case ECOND_MATCH_DOMAIN:
   case ECOND_MATCH_IP:
   case ECOND_MATCH_LOCAL_PART:
-#ifndef EXPAND_LISTMATCH_RHS
+  case ECOND_INLIST:
+  case ECOND_INLISTI:
     sub2_honour_dollar = FALSE;
-#endif
     /* FALLTHROUGH */
 
   case ECOND_CRYPTEQ:
-  case ECOND_INLIST:
-  case ECOND_INLISTI:
   case ECOND_MATCH:
 
   case ECOND_NUM_L:     /* Numerical comparisons */
@@ -3088,13 +3083,24 @@ switch(cond_type = identify_operator(&s, &opname))
       }
 
     case ECOND_MATCH_ADDRESS:  /* Match in an address list */
-      rc = match_address_list(sub[0], TRUE, FALSE, &(sub[1]), NULL, -1, 0,
+      rc = match_address_list(sub[0], TRUE,
+#ifdef EXPAND_LISTMATCH_RHS
+                  TRUE,
+#else
+                  FALSE,
+#endif
+                  &(sub[1]), NULL, -1, 0,
                   CUSS &lookup_value);
       goto MATCHED_SOMETHING;
 
     case ECOND_MATCH_DOMAIN:   /* Match in a domain list */
       rc = match_isinlist(sub[0], &(sub[1]), 0, &domainlist_anchor, NULL,
-    MCL_DOMAIN + MCL_NOEXPAND, TRUE, CUSS &lookup_value);
+#ifdef EXPAND_LISTMATCH_RHS
+              MCL_DOMAIN,
+#else
+              MCL_DOMAIN + MCL_NOEXPAND,
+#endif
+              TRUE, CUSS &lookup_value);
       goto MATCHED_SOMETHING;
 
     case ECOND_MATCH_IP:       /* Match IP address in a host list */
@@ -3120,21 +3126,30 @@ switch(cond_type = identify_operator(&s, &opname))
       cb.host_address + 7 : cb.host_address;
 
     rc = match_check_list(
-           &sub[1],                   /* the list */
-           0,                         /* separator character */
-           &hostlist_anchor,          /* anchor pointer */
-           &nullcache,                /* cache pointer */
-           check_host,                /* function for testing */
-           &cb,                       /* argument for function */
-           MCL_HOST,                  /* type of check */
-           sub[0],                    /* text for debugging */
-           CUSS &lookup_value);       /* where to pass back data */
+        &sub[1],        /* the list */
+        0,            /* separator character */
+        &hostlist_anchor,    /* anchor pointer */
+        &nullcache,        /* cache pointer */
+        check_host,        /* function for testing */
+        &cb,            /* argument for function */
+#ifdef EXPAND_LISTMATCH_RHS
+        MCL_HOST,
+#else
+        MCL_HOST + MCL_NOEXPAND,/* type of check */
+#endif
+        sub[0],            /* text for debugging */
+        CUSS &lookup_value);    /* where to pass back data */
     }
       goto MATCHED_SOMETHING;
 
     case ECOND_MATCH_LOCAL_PART:
       rc = match_isinlist(sub[0], &(sub[1]), 0, &localpartlist_anchor, NULL,
-    MCL_LOCALPART + MCL_NOEXPAND, TRUE, CUSS &lookup_value);
+#ifdef EXPAND_LISTMATCH_RHS
+              MCL_LOCALPART,
+#else
+              MCL_LOCALPART+ MCL_NOEXPAND,
+#endif
+              TRUE, CUSS &lookup_value);
       /* Fall through */
       /* VVVVVVVVVVVV */
       MATCHED_SOMETHING:
@@ -3296,12 +3311,18 @@ switch(cond_type = identify_operator(&s, &opname))
     case ECOND_INLISTI:
       {
       const uschar * list = sub[1];
-      int sep = 0;
+      int sep;
       uschar *save_iterate_item = iterate_item;
       int (*compare)(const uschar *, const uschar *);
 
       DEBUG(D_expand) debug_printf_indent("condition: %s  item: %s\n", opname, sub[0]);
 
+      /* grab any listsep spec, then expand the list */
+
+      sep = matchlist_parse_sep(&list);
+      if (!(list = expand_cstring(list)))
+    goto failout;
+
       tempcond = FALSE;
       compare = cond_type == ECOND_INLISTI
         ? strcmpic : (int (*)(const uschar *, const uschar *)) strcmp;
@@ -3392,13 +3413,19 @@ switch(cond_type = identify_operator(&s, &opname))
   FORMANY:
     {
     const uschar * list;
-    int sep = 0;
+    int sep;
     uschar *save_iterate_item = iterate_item;
 
     DEBUG(D_expand) debug_printf_indent("condition: %s\n", opname);
 
+    /* First expand the list, apart from a leading change-of-separator
+    on non-json lists */
+
     Uskip_whitespace(&s);
     if (*s++ != '{') goto COND_FAILED_CURLY_START;    /* }-for-text-editors */
+
+    sep = is_json ? 0 : matchlist_parse_sep(&s);
+
     if (!(sub[0] = expand_string_internal(s,
       ESI_BRACE_ENDS | ESI_HONOR_DOLLAR | (yield ? ESI_NOFLAGS : ESI_SKIPPING),
       &s, resetok, NULL)))
@@ -3413,7 +3440,7 @@ switch(cond_type = identify_operator(&s, &opname))
 
     /* Call eval_condition once, with result discarded (as if scanning a
     "false" part). This allows us to find the end of the condition, because if
-    the list it empty, we won't actually evaluate the condition for real. */
+    the list is empty, we won't actually evaluate the condition for real. */
 
     if (!(s = eval_condition(sub[1], resetok, NULL)))
       {
@@ -3432,6 +3459,8 @@ switch(cond_type = identify_operator(&s, &opname))
       goto failout;
       }
 
+    /* Now scan the list, checking the condition for each item */
+
     if (yield) *yield = !testfor;
     list = sub[0];
     if (is_json) list = dewrap(string_copy(list), US"[]");
@@ -6358,7 +6387,7 @@ while (*s)
 
     case EITEM_LISTEXTRACT:
       {
-      int field_number = 1;
+      int field_number = 1, sep = 0;
       uschar * save_lookup_value = lookup_value, * sub[2];
       int save_expand_nmax =
         save_expand_strings(save_expand_nstring, save_expand_nlength);
@@ -6375,7 +6404,10 @@ while (*s)
       goto EXPAND_FAILED_CURLY;
       }
 
-    sub[i] = expand_string_internal(s+1,
+    s++;
+    if (i == 1) sep = matchlist_parse_sep(&s);
+
+    sub[i] = expand_string_internal(s,
           ESI_BRACE_ENDS | ESI_HONOR_DOLLAR | flags, &s, &resetok, NULL);
     if (!sub[i])     goto EXPAND_FAILED;                /*{{*/
     if (*s++ != '}')
@@ -6427,7 +6459,8 @@ while (*s)
       /* Extract the numbered element into $value. If
       skipping, just pretend the extraction failed. */
 
-      lookup_value = flags & ESI_SKIPPING ? NULL : expand_getlistele(field_number, sub[1]);
+      lookup_value = flags & ESI_SKIPPING
+    ? NULL : expand_getlistele(field_number, sub[1], sep);
 
       /* If no string follows, $value gets substituted; otherwise there can
       be yes/no strings, as for lookup or if. */
@@ -6559,7 +6592,7 @@ while (*s)
     case EITEM_MAP:
     case EITEM_REDUCE:
       {
-      int sep = 0, save_ptr = gstring_length(yield);
+      int sep, save_ptr = gstring_length(yield);
       uschar outsep[2] = { '\0', '\0' };
       const uschar *list, *expr, *temp;
       uschar * save_iterate_item = iterate_item;
@@ -6574,6 +6607,9 @@ while (*s)
     }
 
       DEBUG(D_expand) debug_printf_indent("%s: evaluate input list list\n", name);
+      /* Check for a list-sep spec before expansion */
+      sep = matchlist_parse_sep(&s);
+
       if (!(list = expand_string_internal(s,
           ESI_BRACE_ENDS | ESI_HONOR_DOLLAR | flags, &s, &resetok, NULL)))
     goto EXPAND_FAILED;                        /*{{*/
@@ -6766,7 +6802,7 @@ while (*s)
 
     case EITEM_SORT:
       {
-      int sep = 0, cond_type;
+      int sep, cond_type;
       const uschar * srclist, * cmp, * xtract;
       uschar * opname, * srcitem;
       const uschar * dstlist = NULL, * dstkeylist = NULL;
@@ -6779,6 +6815,7 @@ while (*s)
     goto EXPAND_FAILED_CURLY;                    /*}*/
     }
 
+      sep = matchlist_parse_sep(&s);
       srclist = expand_string_internal(s,
           ESI_BRACE_ENDS | ESI_HONOR_DOLLAR | flags, &s, &resetok, NULL);
       if (!srclist) goto EXPAND_FAILED;                    /*{{*/
@@ -7545,9 +7582,10 @@ NOT_ITEM: ;
 
       case EOP_LISTCOUNT:
     {
-    int cnt = 0, sep = 0;
+    int cnt = 0, sep;
     uschar * buf = store_get(2, sub);
 
+    sep = matchlist_parse_sep(CUSS &sub);
     while (string_nextinlist(CUSS &sub, &sep, buf, 1)) cnt++;
     yield = string_fmt_append(yield, "%d", cnt);
     break;
diff --git a/src/src/functions.h b/src/src/functions.h
index 11a4b6657..5f26c3dd1 100644
--- a/src/src/functions.h
+++ b/src/src/functions.h
@@ -321,6 +321,7 @@ extern int     match_isinlist(const uschar *, const uschar * const *, int,
          tree_node **, unsigned int *, int, BOOL, const uschar **);
 extern int     match_check_string(const uschar *, const uschar *, int, mcs_flags,
                  const uschar **);
+extern uschar  matchlist_parse_sep(const uschar **);
 
 extern void    message_start(void);
 extern void    message_tidyup(void);
@@ -643,7 +644,7 @@ extern int     vaguely_random_number_fallback(int);
 #endif
 extern int     verify_address(address_item *, FILE *, int, int, int, int,
                  uschar *, uschar *, BOOL *);
-extern int     verify_check_dnsbl(int, const uschar **, uschar **);
+extern int     verify_check_dnsbl(int, const uschar *, uschar **);
 extern int     verify_check_header_address(uschar **, uschar **, int, int, int,
                  uschar *, uschar *, int, int *);
 extern int     verify_check_headers(uschar **);
diff --git a/src/src/match.c b/src/src/match.c
index 252efc6c2..5670388ea 100644
--- a/src/src/match.c
+++ b/src/src/match.c
@@ -370,6 +370,37 @@ return US"";  /* In practice, should never happen */
 
 
 
+/* Check for a change-of-separator specification on the head of a list.
+Handle interpretation of backslash-char, in the same way that expansion would.
+
+Argument:    listp    pointer to list-pointer, updated on return to
+            next char after the spec if there is one; else unchaged
+
+Return:        separator char, or zero for no spec
+*/
+
+uschar
+matchlist_parse_sep(const uschar ** listp)
+{
+const uschar * list = *listp;
+if (Uskip_whitespace(&list) == '<')
+  {
+  const uschar * s = list+1;
+  uschar c = *s == '\\' ? string_interpret_escape(&s) : *s;
+  if (ispunct(c) || iscntrl(c))
+    {
+    DEBUG(D_lists)
+      {
+      uschar s[2] = {0}; *s = c;
+      debug_printf_indent("list separator: '%W'\n", s);
+      }
+    *listp = s+1;    /* next char after the change-of-separator */
+    return c;
+    }
+  }
+return 0;
+}
+
 /*************************************************
 *       Scan list and run matching function      *
 *************************************************/
@@ -454,14 +485,17 @@ if (!*listptr)
 if the type value is greater than or equal to than MCL_NOEXPAND, do not expand
 the list. */
 
+list = *listptr;
 if (type >= MCL_NOEXPAND)
   {
-  list = *listptr;
   type -= MCL_NOEXPAND;       /* Remove the "no expand" flag */
   textonly_re = TRUE;
   }
 else
   {
+  if (sep <= 0)
+    sep = matchlist_parse_sep(&list);
+
   /* If we are searching a domain list, and $domain is not set, set it to the
   subject that is being sought for the duration of the expansion. */
 
@@ -469,11 +503,11 @@ else
     {
     check_string_block *cb = (check_string_block *)arg;
     deliver_domain = string_copy(cb->subject);
-    list = expand_string_2(*listptr, &textonly_re);
+    list = expand_string_2(list, &textonly_re);
     deliver_domain = NULL;
     }
   else
-    list = expand_string_2(*listptr, &textonly_re);
+    list = expand_string_2(list, &textonly_re);
 
   if (!list)
     {
@@ -1133,8 +1167,7 @@ if (pattern[0] == '@' && pattern[1] == '@')
 
     ss = Ustrrchr(list, ':');
     if (!ss) ss = list; else ss++;
-    Uskip_whitespace(&ss);
-    if (*ss == '>')
+    if (Uskip_whitespace(&ss) == '>')
       {
       *ss++ = 0;
       Uskip_whitespace(&ss);
diff --git a/src/src/readconf.c b/src/src/readconf.c
index 7912bca4a..059a4e8f0 100644
--- a/src/src/readconf.c
+++ b/src/src/readconf.c
@@ -1250,9 +1250,9 @@ return s;
 *             Read a name                        *
 *************************************************/
 
-/* The yield is the pointer to the next uschar. Names longer than the
-output space are silently truncated. This function is also used from acl.c when
-parsing ACLs.
+/* The yield is the pointer to the next uschar after the name plus
+and whitespace. Names longer than the output space are silently truncated.
+This function is also used from acl.c when parsing ACLs.
 
 Arguments:
   name      where to put the name
diff --git a/src/src/string.c b/src/src/string.c
index d4fb23cb6..b370cfacc 100644
--- a/src/src/string.c
+++ b/src/src/string.c
@@ -923,14 +923,18 @@ to be conservative. */
 
 while (isspace(*s) && *s != sep) s++;
 
-/* A change of separator is permitted, so look for a leading '<' followed by an
-allowed character. */
+/* A change of separator is permitted (assuming untainted source),
+so look for a leading '<' followed by an allowed character. */
 
 if (sep <= 0)
   {
   if (*s == '<' && (ispunct(s[1]) || iscntrl(s[1])))
     {
-    sep = s[1];
+    if (!is_tainted(s))
+      sep = s[1];
+    else DEBUG(D_any) 
+      debug_printf("attempt to use tainted change-of-seperator spec (%s %d)\n",
+            config_filename, config_lineno);
     if (*++s) ++s;
     while (isspace(*s) && *s != sep) s++;
     }
diff --git a/test/confs/2610 b/test/confs/2610
index 94be1b91f..71a3f4284 100644
--- a/test/confs/2610
+++ b/test/confs/2610
@@ -41,6 +41,7 @@ check_recipient:
       # In list-style lookup, tainted lookup string is ok if server spec comes from main-option
   warn      set acl_m0 =    ok:    hostlist
       hosts =    net-mysql;select * from them where id='${quote_mysql:$local_part}'
+
       # ... but setting a per-query servers spec fails due to the taint
   warn      set acl_m0 =    FAIL4: hostlist
       hosts =    <& net-mysql;servers=SSPEC; select * from them where id='${quote_mysql:$local_part}'
diff --git a/test/log/2610 b/test/log/2610
index 4acbbd873..cd8b2a915 100644
--- a/test/log/2610
+++ b/test/log/2610
@@ -1,5 +1,5 @@
 1999-03-02 09:44:33 10HmaX-000000005vi-0000 <= CALLER@??? U=CALLER P=local S=sss
-1999-03-02 09:44:33 10HmaX-000000005vi-0000 tainted search query is not properly quoted (router r1, TESTSUITE/test-config 68): select name from them where id='ph10' limit 1
-1999-03-02 09:44:33 10HmaX-000000005vi-0000 tainted search query is not properly quoted (transport t1, TESTSUITE/test-config 82): select id from them where id='ph10'
+1999-03-02 09:44:33 10HmaX-000000005vi-0000 tainted search query is not properly quoted (router r1, TESTSUITE/test-config 69): select name from them where id='ph10' limit 1
+1999-03-02 09:44:33 10HmaX-000000005vi-0000 tainted search query is not properly quoted (transport t1, TESTSUITE/test-config 83): select id from them where id='ph10'
 1999-03-02 09:44:33 10HmaX-000000005vi-0000 => ph10 <ph10@???> R=r1 T=t1
 1999-03-02 09:44:33 10HmaX-000000005vi-0000 Completed
diff --git a/test/paniclog/2610 b/test/paniclog/2610
index 917a0a801..29493f888 100644
--- a/test/paniclog/2610
+++ b/test/paniclog/2610
@@ -1 +1 @@
-1999-03-02 09:44:33 10HmaX-000000005vi-0000 tainted search query is not properly quoted (router r1, TESTSUITE/test-config 68): select name from them where id='ph10' limit 1
+1999-03-02 09:44:33 10HmaX-000000005vi-0000 tainted search query is not properly quoted (router r1, TESTSUITE/test-config 69): select name from them where id='ph10' limit 1
diff --git a/test/scripts/0000-Basic/0002 b/test/scripts/0000-Basic/0002
index 0ed3e0dff..6e0d6dce6 100644
--- a/test/scripts/0000-Basic/0002
+++ b/test/scripts/0000-Basic/0002
@@ -1153,6 +1153,7 @@ ${if inlist{aa}{aa} {in list}{not in list}}
 ${if !inlist{aa}{aa} {not in list}{in list}}
 ****
 # listextract from tainted list
-exim -be -oMs my.target.host.name
-'\${listextract {2} {<. $sender_host_name}}'  =>   '${listextract {2} {<. $sender_host_name}}'
+exim -d-all+expand -be
+set,t acl_m0 = my:target:string:list
+'\${listextract {2} {$acl_m0}}'  =>   '${listextract {2} {$acl_m0}}'
 ****
diff --git a/test/stderr/0002 b/test/stderr/0002
index 8b6a1a446..2a3ef8742 100644
--- a/test/stderr/0002
+++ b/test/stderr/0002
@@ -554,8 +554,9 @@ host in helo_accept_junk_hosts? no (option unset)
 using ACL "connect1"
 processing ACL connect1 "deny" (TESTSUITE/test-config 45)
 check hosts = <\n partial-lsearch;TESTSUITE/aux-fixed/0002.lsearch \n 1.2.3.4
-host in "<
- partial-lsearch;TESTSUITE/aux-fixed/0002.lsearch 
+list separator: '↩
+'
+host in " partial-lsearch;TESTSUITE/aux-fixed/0002.lsearch 
  1.2.3.4"?
  list element: partial-lsearch;TESTSUITE/aux-fixed/0002.lsearch
  sender host name required, to match against partial-lsearch;TESTSUITE/aux-fixed/0002.lsearch
@@ -588,8 +589,7 @@ host in "<
    in TESTSUITE/aux-fixed/0002.lsearch
  creating new cache entry
  lookup yielded: 
-  host in "<
-  partial-lsearch;TESTSUITE/aux-fixed/0002.lsearch 
+  host in " partial-lsearch;TESTSUITE/aux-fixed/0002.lsearch 
   1.2.3.4"? yes (matched "partial-lsearch;TESTSUITE/aux-fixed/0002.lsearch")
 deny: condition test succeeded in ACL connect1
 end of ACL connect1: DENY
@@ -827,3 +827,49 @@ sender address = CALLER@???
    ╎ ::1 in "<; aaaa:bbbb"? no (malformed IPv6 address or address mask: aaaa:bbbb)
    search_tidyup called

>>>>>>>>>>>>>>>> Exim pid=p1240 (fresh-exec) terminating with rc=0 >>>>>>>>>>>>>>>>

+Exim version x.yz ....
+Hints DB:
+environment after trimming:
+ USER=CALLER
+configuration file is TESTSUITE/test-config
+admin user
+dropping to exim gid; retaining priv uid
+try option gecos_pattern
+try option gecos_name
+try option unknown_login
+ ╭considering: '\${listextract░{2}░{$acl_m0}}'░░=>░░░'${listextract░{2}░{$acl_m0}}'
+ ├───────text: '
+ ├considering: \${listextract░{2}░{$acl_m0}}'░░=>░░░'${listextract░{2}░{$acl_m0}}'
+ ├backslashed: '\$'
+ ├considering: {listextract░{2}░{$acl_m0}}'░░=>░░░'${listextract░{2}░{$acl_m0}}'
+ ├───────text: {listextract░{2
+ ├considering: }░{$acl_m0}}'░░=>░░░'${listextract░{2}░{$acl_m0}}'
+ ├───────text: }░{
+ ├considering: $acl_m0}}'░░=>░░░'${listextract░{2}░{$acl_m0}}'
+ ├──────value: my:target:string:list
+            ╰──(tainted)
+ ├considering: }}'░░=>░░░'${listextract░{2}░{$acl_m0}}'
+ ├───────text: }
+ ├considering: }'░░=>░░░'${listextract░{2}░{$acl_m0}}'
+ ├───────text: }'░░=>░░░'
+ ├considering: ${listextract░{2}░{$acl_m0}}'
+  ╭considering: 2}░{$acl_m0}}'
+  ├───────text: 2
+  ├considering: }░{$acl_m0}}'
+  ├───expanded: 2
+  ╰─────result: 2
+  ╭considering: $acl_m0}}'
+  ├──────value: my:target:string:list
+             ╰──(tainted)
+  ├considering: }}'
+  ├───expanded: $acl_m0
+  ╰─────result: my:target:string:list
+             ╰──(tainted)
+ ├───item-res: target
+            ╰──(tainted)
+ ├considering: '
+ ├───────text: '
+ ├───expanded: '\${listextract░{2}░{$acl_m0}}'░░=>░░░'${listextract░{2}░{$acl_m0}}'
+ ╰─────result: '${listextract░{2}░{my:target:string:list}}'░░=>░░░'target'
+            ╰──(tainted)
+>>>>>>>>>>>>>>>> Exim pid=p1241 (fresh-exec) terminating with rc=0 >>>>>>>>>>>>>>>>
diff --git a/test/stderr/0023 b/test/stderr/0023
index d825511b9..fe86a317d 100644
--- a/test/stderr/0023
+++ b/test/stderr/0023
@@ -1668,7 +1668,7 @@ LOG: H=(test) [V4NET.99.99.96] F=<> temporarily rejected RCPT <x@y>: host lookup

>>> using ACL "acl_29_29_29"
>>> processing ACL acl_29_29_29 "deny" (TESTSUITE/test-config 154)
>>> check dnslists = test.ex/$sender_address_domain

->>>                = test.ex/localhost
+>>> expanded list: test.ex/localhost

>>> dnslists check: test.ex/localhost
>>> new DNS lookup for localhost.test.ex
>>> dnslists: wrote cache entry, ttl=3600

@@ -1680,7 +1680,7 @@ LOG: H=(test) [29.29.29.29] F=<a@localhost> rejected RCPT <x@y>
>>> using ACL "acl_29_29_29"
>>> processing ACL acl_29_29_29 "deny" (TESTSUITE/test-config 154)
>>> check dnslists = test.ex/$sender_address_domain

->>>                = test.ex/elsewhere
+>>> expanded list: test.ex/elsewhere

>>> dnslists check: test.ex/elsewhere
>>> new DNS lookup for elsewhere.test.ex
>>> dnslists: wrote cache entry, ttl=3000

@@ -1706,7 +1706,7 @@ LOG: H=(test) [29.29.29.29] F=<a@localhost> rejected RCPT <x@y>
>>> processing ACL acl_30_30_30 "deny" (TESTSUITE/test-config 161)
>>> message: domain=$dnslist_domain\nvalue=$dnslist_value\nmatched=$dnslist_matched\ntext="$dnslist_text"
>>> check dnslists = test.ex=V4NET.0.0.1,127.0.0.2/$sender_address_domain

->>>                = test.ex=V4NET.0.0.1,127.0.0.2/ten-1
+>>> expanded list: test.ex=V4NET.0.0.1,127.0.0.2/ten-1

>>> dnslists check: test.ex=V4NET.0.0.1,127.0.0.2/ten-1
>>> new DNS lookup for ten-1.test.ex
>>> dnslists: wrote cache entry, ttl=3600

@@ -1719,7 +1719,7 @@ LOG: H=(test) [30.30.30.30] F=<a@ten-1> rejected RCPT <x@y>: domain=test.ex
>>> processing ACL acl_30_30_30 "deny" (TESTSUITE/test-config 161)
>>> message: domain=$dnslist_domain\nvalue=$dnslist_value\nmatched=$dnslist_matched\ntext="$dnslist_text"
>>> check dnslists = test.ex=V4NET.0.0.1,127.0.0.2/$sender_address_domain

->>>                = test.ex=V4NET.0.0.1,127.0.0.2/ten-2
+>>> expanded list: test.ex=V4NET.0.0.1,127.0.0.2/ten-2

>>> dnslists check: test.ex=V4NET.0.0.1,127.0.0.2/ten-2
>>> new DNS lookup for ten-2.test.ex
>>> dnslists: wrote cache entry, ttl=3600

@@ -1737,7 +1737,7 @@ LOG: H=(test) [30.30.30.30] F=<a@ten-1> rejected RCPT <x@y>: domain=test.ex
>>> processing ACL acl_30_30_30 "deny" (TESTSUITE/test-config 161)
>>> message: domain=$dnslist_domain\nvalue=$dnslist_value\nmatched=$dnslist_matched\ntext="$dnslist_text"
>>> check dnslists = test.ex=V4NET.0.0.1,127.0.0.2/$sender_address_domain

->>>                = test.ex=V4NET.0.0.1,127.0.0.2/13.12.11.V4NET.rbl
+>>> expanded list: test.ex=V4NET.0.0.1,127.0.0.2/13.12.11.V4NET.rbl

>>> dnslists check: test.ex=V4NET.0.0.1,127.0.0.2/13.12.11.V4NET.rbl
>>> new DNS lookup for 13.12.11.V4NET.rbl.test.ex
>>> dnslists: wrote cache entry, ttl=3

@@ -1761,7 +1761,7 @@ LOG: H=(test) [30.30.30.30] F=<a@???> rejected RCPT <x@y>: domain
>>> using ACL "acl_31_31_31"
>>> processing ACL acl_31_31_31 "deny" (TESTSUITE/test-config 167)
>>> check dnslists = test.ex/$sender_address_domain+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+END

->>>                = test.ex/y+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+END
+>>> expanded list: test.ex/y+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+END

>>> dnslists check: test.ex/y+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+END

LOG: dnslist query is too long (ignored): y+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+extra+END.test.ex...
>>> deny: condition test failed in ACL acl_31_31_31

diff --git a/test/stderr/0149 b/test/stderr/0149
index 81bf94397..1dcf8893e 100644
--- a/test/stderr/0149
+++ b/test/stderr/0149
@@ -7,7 +7,7 @@ routing x@ten
 --------> domainlist1 router <--------
 local_part=x domain=ten
 checking domains
-ten in "<- test1 - test2-test3--4"? no (end of list)
+ten in " test1 - test2-test3--4"? no (end of list)
 ten in domains? yes (end of list)
 calling domainlist1 router
 domainlist1 router called for x@ten
@@ -33,7 +33,7 @@ routing y@two
 --------> domainlist1 router <--------
 local_part=y domain=two
 checking domains
-two in "<- test1 - test2-test3--4"? no (end of list)
+two in " test1 - test2-test3--4"? no (end of list)
 two in domains? yes (end of list)
 calling domainlist1 router
 domainlist1 router called for y@two
@@ -128,7 +128,7 @@ routing x@one
 --------> domainlist1 router <--------
 local_part=x domain=one
 checking domains
-one in "<- test1 - test2-test3--4"? no (end of list)
+one in " test1 - test2-test3--4"? no (end of list)
 one in domains? yes (end of list)
 calling domainlist1 router
 domainlist1 router called for x@one
@@ -168,7 +168,7 @@ routing x@six
 --------> domainlist1 router <--------
 local_part=x domain=six
 checking domains
-six in "<- test1 - test2-test3--4"? no (end of list)
+six in " test1 - test2-test3--4"? no (end of list)
 six in domains? yes (end of list)
 calling domainlist1 router
 domainlist1 router called for x@six
diff --git a/test/stderr/0275 b/test/stderr/0275
index f5891bb9a..d8ee843ae 100644
--- a/test/stderr/0275
+++ b/test/stderr/0275
@@ -145,13 +145,14 @@ test.ex in domains?
  cached lookup data = NULL
  list element: +n2_domains
   start sublist n2_domains
-   test.ex in "<; never2.ex ; +n1_domains"?
+   list separator: ';'
+   test.ex in " never2.ex ; +n1_domains"?
    ╎list element: never2.ex
    ╎list element: +n1_domains
    ╎ start sublist n1_domains
    ╎cached no match for +n1_domains
    ╎cached lookup data = NULL
-   test.ex in "<; never2.ex ; +n1_domains"? no (end of list)
+   test.ex in " never2.ex ; +n1_domains"? no (end of list)
   end sublist n2_domains
  list element: !+local_domains
   start sublist local_domains
@@ -408,13 +409,14 @@ test.ex in domains?
  cached lookup data = NULL
  list element: +n2_domains
   start sublist n2_domains
-   test.ex in "<; never2.ex ; +n1_domains"?
+   list separator: ';'
+   test.ex in " never2.ex ; +n1_domains"?
    ╎list element: never2.ex
    ╎list element: +n1_domains
    ╎ start sublist n1_domains
    ╎cached no match for +n1_domains
    ╎cached lookup data = NULL
-   test.ex in "<; never2.ex ; +n1_domains"? no (end of list)
+   test.ex in " never2.ex ; +n1_domains"? no (end of list)
   end sublist n2_domains
  list element: !+local_domains
   start sublist local_domains
diff --git a/test/stderr/0277 b/test/stderr/0277
index b92d4d832..ecf66e185 100644
--- a/test/stderr/0277
+++ b/test/stderr/0277
@@ -88,7 +88,8 @@ host in sender_unqualified_hosts?
  cached no match for +lookup_hosts
  list element: !+n2_hosts
   start sublist n2_hosts
-  host in "<; V4NET.2.2.2 ; +n1_hosts"?
+   list separator: ';'
+  host in " V4NET.2.2.2 ; +n1_hosts"?
    ╎list element: V4NET.2.2.2
    ╎list element: +n1_hosts
    ╎ start sublist n1_hosts
@@ -96,7 +97,7 @@ host in sender_unqualified_hosts?
    ╎   list element: V4NET.1.1.1
    ╎  host in "V4NET.1.1.1"? no (end of list)
    ╎ end sublist n1_hosts
-  host in "<; V4NET.2.2.2 ; +n1_hosts"? no (end of list)
+  host in " V4NET.2.2.2 ; +n1_hosts"? no (end of list)
   end sublist n2_hosts
 host in sender_unqualified_hosts? yes (end of list)
 host in recipient_unqualified_hosts? no (option unset)
@@ -159,7 +160,8 @@ host in sender_unqualified_hosts?
  cached no match for +lookup_hosts
  list element: !+n2_hosts
   start sublist n2_hosts
-  host in "<; V4NET.2.2.2 ; +n1_hosts"?
+   list separator: ';'
+  host in " V4NET.2.2.2 ; +n1_hosts"?
    ╎list element: V4NET.2.2.2
    ╎list element: +n1_hosts
    ╎ start sublist n1_hosts
@@ -167,7 +169,7 @@ host in sender_unqualified_hosts?
    ╎   list element: V4NET.1.1.1
    ╎  host in "V4NET.1.1.1"? no (end of list)
    ╎ end sublist n1_hosts
-  host in "<; V4NET.2.2.2 ; +n1_hosts"? no (end of list)
+  host in " V4NET.2.2.2 ; +n1_hosts"? no (end of list)
   end sublist n2_hosts
 host in sender_unqualified_hosts? yes (end of list)
 host in recipient_unqualified_hosts? no (option unset)
@@ -230,7 +232,8 @@ host in sender_unqualified_hosts?
  cached no match for +lookup_hosts
  list element: !+n2_hosts
   start sublist n2_hosts
-  host in "<; V4NET.2.2.2 ; +n1_hosts"?
+   list separator: ';'
+  host in " V4NET.2.2.2 ; +n1_hosts"?
    ╎list element: V4NET.2.2.2
    ╎list element: +n1_hosts
    ╎ start sublist n1_hosts
@@ -238,7 +241,7 @@ host in sender_unqualified_hosts?
    ╎   list element: V4NET.1.1.1
    ╎   host in "V4NET.1.1.1"? yes (matched "V4NET.1.1.1")
    ╎ end sublist n1_hosts
-   ╎host in "<; V4NET.2.2.2 ; +n1_hosts"? yes (matched "+n1_hosts")
+   ╎host in " V4NET.2.2.2 ; +n1_hosts"? yes (matched "+n1_hosts")
   end sublist n2_hosts
   host in sender_unqualified_hosts? no (matched "!+n2_hosts")
 host in recipient_unqualified_hosts? no (option unset)
@@ -301,9 +304,10 @@ host in sender_unqualified_hosts?
  cached no match for +lookup_hosts
  list element: !+n2_hosts
   start sublist n2_hosts
-  host in "<; V4NET.2.2.2 ; +n1_hosts"?
+   list separator: ';'
+  host in " V4NET.2.2.2 ; +n1_hosts"?
    ╎list element: V4NET.2.2.2
-   ╎host in "<; V4NET.2.2.2 ; +n1_hosts"? yes (matched "V4NET.2.2.2")
+   ╎host in " V4NET.2.2.2 ; +n1_hosts"? yes (matched "V4NET.2.2.2")
   end sublist n2_hosts
   host in sender_unqualified_hosts? no (matched "!+n2_hosts")
 host in recipient_unqualified_hosts? no (option unset)
diff --git a/test/stderr/0278 b/test/stderr/0278
index fd94ee0c0..08709b257 100644
--- a/test/stderr/0278
+++ b/test/stderr/0278
@@ -51,13 +51,14 @@ CALLER in local_parts?
  cached lookup data = NULL
  list element: +n2_localparts
   start sublist n2_localparts
-   CALLER in "<; never2 ; +n1_localparts"?
+   list separator: ';'
+   CALLER in " never2 ; +n1_localparts"?
    ╎list element: never2
    ╎list element: +n1_localparts
    ╎ start sublist n1_localparts
    ╎cached no match for +n1_localparts
    ╎cached lookup data = NULL
-   CALLER in "<; never2 ; +n1_localparts"? no (end of list)
+   CALLER in " never2 ; +n1_localparts"? no (end of list)
   end sublist n2_localparts
  list element: !+local_localparts
   start sublist local_localparts
@@ -209,13 +210,14 @@ CALLER in local_parts?
  cached lookup data = NULL
  list element: +n2_localparts
   start sublist n2_localparts
-   CALLER in "<; never2 ; +n1_localparts"?
+   list separator: ';'
+   CALLER in " never2 ; +n1_localparts"?
    ╎list element: never2
    ╎list element: +n1_localparts
    ╎ start sublist n1_localparts
    ╎cached no match for +n1_localparts
    ╎cached lookup data = NULL
-   CALLER in "<; never2 ; +n1_localparts"? no (end of list)
+   CALLER in " never2 ; +n1_localparts"? no (end of list)
   end sublist n2_localparts
  list element: !+local_localparts
   start sublist local_localparts
@@ -362,13 +364,14 @@ unknown in local_parts?
  cached lookup data = NULL
  list element: +n2_localparts
   start sublist n2_localparts
-   unknown in "<; never2 ; +n1_localparts"?
+   list separator: ';'
+   unknown in " never2 ; +n1_localparts"?
    ╎list element: never2
    ╎list element: +n1_localparts
    ╎ start sublist n1_localparts
    ╎cached no match for +n1_localparts
    ╎cached lookup data = NULL
-   unknown in "<; never2 ; +n1_localparts"? no (end of list)
+   unknown in " never2 ; +n1_localparts"? no (end of list)
   end sublist n2_localparts
  list element: !+local_localparts
   start sublist local_localparts
diff --git a/test/stderr/0279 b/test/stderr/0279
index bbd876568..9088c39fe 100644
--- a/test/stderr/0279
+++ b/test/stderr/0279
@@ -68,14 +68,15 @@ CALLER@??? in senders?
  cached lookup data = NULL
  list element: +n2_addresses
   start sublist n2_addresses
-   CALLER@??? in "<; never2@??? ; +n1_addresses"?
+   list separator: ';'
+   CALLER@??? in " never2@??? ; +n1_addresses"?
    ╎list element: never2@???
    ╎address match test: subject=CALLER@??? pattern=never2@???
    ╎list element: +n1_addresses
    ╎ start sublist n1_addresses
    ╎cached no match for +n1_addresses
    ╎cached lookup data = NULL
-   CALLER@??? in "<; never2@??? ; +n1_addresses"? no (end of list)
+   CALLER@??? in " never2@??? ; +n1_addresses"? no (end of list)
   end sublist n2_addresses
  list element: !+local_addresses
   start sublist local_addresses
diff --git a/test/stderr/0475 b/test/stderr/0475
index 34053c1e3..bb601718a 100644
--- a/test/stderr/0475
+++ b/test/stderr/0475
@@ -33,18 +33,18 @@ LOG: H=(test) [V4NET.0.0.0] F=<> rejected RCPT <a2@b>

>>> using ACL "a3"
>>> processing ACL a3 "deny" (TESTSUITE/test-config 23)
>>> check hosts = <; fe80::1

->>> host in "<; fe80::1"?
+>>> host in " fe80::1"?
>>> list element: fe80::1

->>> host in "<; fe80::1"? no (end of list)
+>>> host in " fe80::1"? no (end of list)
>>> deny: condition test failed in ACL a3
>>> end of ACL a3: implicit DENY

LOG: H=(test) [V4NET.0.0.0] F=<> rejected RCPT <a3@b>
>>> using ACL "a4"
>>> processing ACL a4 "deny" (TESTSUITE/test-config 26)
>>> check hosts = <; fe80:1

->>> host in "<; fe80:1"?
+>>> host in " fe80:1"?
>>> list element: fe80:1

->>> host in "<; fe80:1"? no (malformed IPv6 address or address mask: fe80:1)
+>>> host in " fe80:1"? no (malformed IPv6 address or address mask: fe80:1)
LOG: list matching forced to fail: malformed IPv6 address or address mask: fe80:1
>>> deny: condition test failed in ACL a4
>>> end of ACL a4: implicit DENY

diff --git a/test/stderr/1000 b/test/stderr/1000
index 5f289462d..46bffdf92 100644
--- a/test/stderr/1000
+++ b/test/stderr/1000
@@ -10,9 +10,9 @@
>>> processing ACL check_connect "warn" (TESTSUITE/test-config 21)
>>> l_message: matched hostlist
>>> check hosts = <; 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex

->>> host in "<; 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"?
+>>> host in " 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"?
>>> list element: 2001:ab8:37f:20:0:0:0:1

->>> host in "<; 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"? yes (matched "2001:ab8:37f:20:0:0:0:1")
+>>> host in " 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"? yes (matched "2001:ab8:37f:20:0:0:0:1")
>>> warn: condition test succeeded in ACL check_connect

LOG: H=[2001:ab8:37f:20::1] Warning: matched hostlist
>>> processing ACL check_connect "accept" (TESTSUITE/test-config 24)

@@ -43,13 +43,13 @@ LOG: H=[2001:ab8:37f:20::1] rejected connection in "connect" ACL
>>> processing ACL check_connect "warn" (TESTSUITE/test-config 21)
>>> l_message: matched hostlist
>>> check hosts = <; 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex

->>> host in "<; 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"?
+>>> host in " 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"?
>>> list element: 2001:ab8:37f:20:0:0:0:1
>>> list element: v6.test.ex

MUNGED: ::1 will be omitted in what follows
>>> get[host|ipnode]byname[2] looked up these IP addresses:
>>> name=v6.test.ex address=V6NET:ffff:836f:a00:a:800:200a:c032

->>> host in "<; 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"? no (end of list)
+>>> host in " 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"? no (end of list)
>>> warn: condition test failed in ACL check_connect
>>> processing ACL check_connect "accept" (TESTSUITE/test-config 24)
>>> check condition = ${if eq{$sender_host_address}{2001:0ab8:037f:0020:0000:0000:0000:0001}}

@@ -69,13 +69,13 @@ LOG: H=test3.ipv6.test.ex [V6NET:1234:5:6:7:8:abc:d] rejected connection in "con
>>> processing ACL check_connect "warn" (TESTSUITE/test-config 21)
>>> l_message: matched hostlist
>>> check hosts = <; 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex

->>> host in "<; 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"?
+>>> host in " 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"?
>>> list element: 2001:ab8:37f:20:0:0:0:1
>>> list element: v6.test.ex

MUNGED: ::1 will be omitted in what follows
>>> get[host|ipnode]byname[2] looked up these IP addresses:
>>> name=v6.test.ex address=V6NET:ffff:836f:a00:a:800:200a:c032

->>> host in "<; 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"? yes (matched "v6.test.ex")
+>>> host in " 2001:ab8:37f:20:0:0:0:1 ; v6.test.ex"? yes (matched "v6.test.ex")
>>> warn: condition test succeeded in ACL check_connect

LOG: H=[V6NET:ffff:836f:a00:a:800:200a:c032] Warning: matched hostlist
>>> processing ACL check_connect "accept" (TESTSUITE/test-config 24)

diff --git a/test/stderr/1002 b/test/stderr/1002
index efaeb0e17..eb9d3ac21 100644
--- a/test/stderr/1002
+++ b/test/stderr/1002
@@ -26,25 +26,25 @@
>>> processing ACL acl_rcpt_6 "require" (TESTSUITE/test-config 44)
>>> message: domain doesn't match @mx_any/ignore=<;127.0.0.1;::1
>>> check domains = <+ @mx_any/ignore=<;127.0.0.1;::1

->>> mxt11a.test.ex in "<+ @mx_any/ignore=<;127.0.0.1;::1"?
+>>> mxt11a.test.ex in " @mx_any/ignore=<;127.0.0.1;::1"?
>>> list element: @mx_any/ignore=<;127.0.0.1;::1
>>> check dnssec require list
>>> check dnssec request list

->>> ::1 in "<;127.0.0.1;::1"?
+>>> ::1 in "127.0.0.1;::1"?
>>> list element: 127.0.0.1
>>> list element: ::1

->>> ::1 in "<;127.0.0.1;::1"? yes (matched "::1")
->>> 127.0.0.1 in "<;127.0.0.1;::1"?
+>>> ::1 in "127.0.0.1;::1"? yes (matched "::1")
+>>> 127.0.0.1 in "127.0.0.1;::1"?
>>> list element: 127.0.0.1

->>> 127.0.0.1 in "<;127.0.0.1;::1"? yes (matched "127.0.0.1")
->>> V4NET.0.0.1 in "<;127.0.0.1;::1"?
+>>> 127.0.0.1 in "127.0.0.1;::1"? yes (matched "127.0.0.1")
+>>> V4NET.0.0.1 in "127.0.0.1;::1"?
>>> list element: 127.0.0.1
>>> list element: ::1

->>> V4NET.0.0.1 in "<;127.0.0.1;::1"? no (end of list)
+>>> V4NET.0.0.1 in "127.0.0.1;::1"? no (end of list)
>>> ten-1.test.ex in hosts_treat_as_local?
>>> list element: other1.test.ex
>>> ten-1.test.ex in hosts_treat_as_local? no (end of list)

->>> mxt11a.test.ex in "<+ @mx_any/ignore=<;127.0.0.1;::1"? no (end of list)
+>>> mxt11a.test.ex in " @mx_any/ignore=<;127.0.0.1;::1"? no (end of list)
>>> require: condition test failed in ACL acl_rcpt_6
>>> end of ACL acl_rcpt_6: not OK

 LOG: H=(test) [V4NET.1.1.1] F=<x@y> rejected RCPT <6@???>: domain doesn't match @mx_any/ignore=<;127.0.0.1;::1
diff --git a/test/stderr/2610 b/test/stderr/2610
index dc54f12bc..af6db6216 100644
--- a/test/stderr/2610
+++ b/test/stderr/2610
@@ -621,11 +621,12 @@ close MYSQL connection: 127.0.0.1:PORT_N/test/root
 01:01:01 p1235   lookup failed
 01:01:01 p1235  host in "net-mysql;select * from them where id='c'"? no (end of list)
 01:01:01 p1235  warn: condition test failed in ACL check_recipient
-01:01:01 p1235  processing ACL check_recipient "warn" (TESTSUITE/test-config 45)
+01:01:01 p1235  processing ACL check_recipient "warn" (TESTSUITE/test-config 46)
 01:01:01 p1235  check set acl_m0 = FAIL4: hostlist
 01:01:01 p1235  check hosts = <& net-mysql;servers=127.0.0.1::PORT_N/test/root/pass; select * from them where id='${quote_mysql:$local_part}'
-01:01:01 p1235   ╭considering: <&░net-mysql;servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='${quote_mysql:$local_part}'
-01:01:01 p1235   ├───────text: <&░net-mysql;servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='
+01:01:01 p1235  list separator: '&'
+01:01:01 p1235   ╭considering: ░net-mysql;servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='${quote_mysql:$local_part}'
+01:01:01 p1235   ├───────text: ░net-mysql;servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='
 01:01:01 p1235   ├considering: ${quote_mysql:$local_part}'
 01:01:01 p1235    ╭considering: $local_part}'
 01:01:01 p1235    ├──────value: c
@@ -638,10 +639,10 @@ close MYSQL connection: 127.0.0.1:PORT_N/test/root
 01:01:01 p1235              ╰──(tainted, quoted:mysql)
 01:01:01 p1235   ├considering: '
 01:01:01 p1235   ├───────text: '
-01:01:01 p1235   ├───expanded: <&░net-mysql;servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='${quote_mysql:$local_part}'
-01:01:01 p1235   ╰─────result: <&░net-mysql;servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='c'
+01:01:01 p1235   ├───expanded: ░net-mysql;servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='${quote_mysql:$local_part}'
+01:01:01 p1235   ╰─────result: ░net-mysql;servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='c'
 01:01:01 p1235              ╰──(tainted, quoted:mysql)
-01:01:01 p1235  host in "<& net-mysql;servers=127.0.0.1::PORT_N/test/root/pass; select * from them where id='c'"?
+01:01:01 p1235  host in " net-mysql;servers=127.0.0.1::PORT_N/test/root/pass; select * from them where id='c'"?
 01:01:01 p1235   list element: net-mysql;servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='c'
 01:01:01 p1235   search_open: mysql "NULL"
 01:01:01 p1235     cached open
@@ -654,18 +655,19 @@ close MYSQL connection: 127.0.0.1:PORT_N/test/root
 01:01:01 p1235                                (tainted, quoted:mysql)
 01:01:01 p1235   MySQL query: "servers=127.0.0.1::PORT_N/test/root/pass; select * from them where id='c'" opts 'NULL'
 01:01:01 p1235  LOG: MAIN
-01:01:01 p1235    Exim configuration error in ACL verb at line 45 of TESTSUITE/test-config:
+01:01:01 p1235    Exim configuration error in ACL verb at line 46 of TESTSUITE/test-config:
 01:01:01 p1235    WARNING: obsolete syntax used for lookup
 01:01:01 p1235   lookup deferred: MySQL server "127.0.0.1:PORT_N/test" is tainted
-01:01:01 p1235  host in "<& net-mysql;servers=127.0.0.1::PORT_N/test/root/pass; select * from them where id='c'"? list match deferred for net-mysql;servers=127.0.0.1::1223/test/root/pass; select * from them where id='c'
+01:01:01 p1235  host in " net-mysql;servers=127.0.0.1::PORT_N/test/root/pass; select * from them where id='c'"? list match deferred for net-mysql;servers=127.0.0.1::1223/test/root/pass; select * from them where id='c'
 01:01:01 p1235  warn: condition test deferred in ACL check_recipient
 01:01:01 p1235  LOG: MAIN
-01:01:01 p1235    H=(test) [10.0.0.0] Warning: ACL 'warn' statement skipped (in ACL check_recipient at line 45 of TESTSUITE/test-config): condition test deferred: MySQL server "127.0.0.1:PORT_N/test" is tainted
-01:01:01 p1235  processing ACL check_recipient "warn" (TESTSUITE/test-config 50)
+01:01:01 p1235    H=(test) [10.0.0.0] Warning: ACL 'warn' statement skipped (in ACL check_recipient at line 46 of TESTSUITE/test-config): condition test deferred: MySQL server "127.0.0.1:PORT_N/test" is tainted
+01:01:01 p1235  processing ACL check_recipient "warn" (TESTSUITE/test-config 51)
 01:01:01 p1235  check set acl_m0 = FAIL5: hostlist
 01:01:01 p1235  check hosts = <& net-mysql,servers=127.0.0.1::PORT_N/test/root/pass; select * from them where id='${quote_mysql:$local_part}'
-01:01:01 p1235   ╭considering: <&░net-mysql,servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='${quote_mysql:$local_part}'
-01:01:01 p1235   ├───────text: <&░net-mysql,servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='
+01:01:01 p1235  list separator: '&'
+01:01:01 p1235   ╭considering: ░net-mysql,servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='${quote_mysql:$local_part}'
+01:01:01 p1235   ├───────text: ░net-mysql,servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='
 01:01:01 p1235   ├considering: ${quote_mysql:$local_part}'
 01:01:01 p1235    ╭considering: $local_part}'
 01:01:01 p1235    ├──────value: c
@@ -678,10 +680,10 @@ close MYSQL connection: 127.0.0.1:PORT_N/test/root
 01:01:01 p1235              ╰──(tainted, quoted:mysql)
 01:01:01 p1235   ├considering: '
 01:01:01 p1235   ├───────text: '
-01:01:01 p1235   ├───expanded: <&░net-mysql,servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='${quote_mysql:$local_part}'
-01:01:01 p1235   ╰─────result: <&░net-mysql,servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='c'
+01:01:01 p1235   ├───expanded: ░net-mysql,servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='${quote_mysql:$local_part}'
+01:01:01 p1235   ╰─────result: ░net-mysql,servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='c'
 01:01:01 p1235              ╰──(tainted, quoted:mysql)
-01:01:01 p1235  host in "<& net-mysql,servers=127.0.0.1::PORT_N/test/root/pass; select * from them where id='c'"?
+01:01:01 p1235  host in " net-mysql,servers=127.0.0.1::PORT_N/test/root/pass; select * from them where id='c'"?
 01:01:01 p1235   list element: net-mysql,servers=127.0.0.1::PORT_N/test/root/pass;░select░*░from░them░where░id='c'
 01:01:01 p1235   search_open: mysql "NULL"
 01:01:01 p1235     cached open
@@ -694,11 +696,11 @@ close MYSQL connection: 127.0.0.1:PORT_N/test/root
 01:01:01 p1235                                (tainted, quoted:mysql)
 01:01:01 p1235   MySQL query: " select * from them where id='c'" opts 'servers=127.0.0.1::PORT_N/test/root/pass'
 01:01:01 p1235   lookup deferred: MySQL server "127.0.0.1:PORT_N/test" is tainted
-01:01:01 p1235  host in "<& net-mysql,servers=127.0.0.1::PORT_N/test/root/pass; select * from them where id='c'"? list match deferred for net-mysql,servers=127.0.0.1::1223/test/root/pass; select * from them where id='c'
+01:01:01 p1235  host in " net-mysql,servers=127.0.0.1::PORT_N/test/root/pass; select * from them where id='c'"? list match deferred for net-mysql,servers=127.0.0.1::1223/test/root/pass; select * from them where id='c'
 01:01:01 p1235  warn: condition test deferred in ACL check_recipient
 01:01:01 p1235  LOG: MAIN
-01:01:01 p1235    H=(test) [10.0.0.0] Warning: ACL 'warn' statement skipped (in ACL check_recipient at line 50 of TESTSUITE/test-config): condition test deferred: MySQL server "127.0.0.1:PORT_N/test" is tainted
-01:01:01 p1235  processing ACL check_recipient "accept" (TESTSUITE/test-config 53)
+01:01:01 p1235    H=(test) [10.0.0.0] Warning: ACL 'warn' statement skipped (in ACL check_recipient at line 51 of TESTSUITE/test-config): condition test deferred: MySQL server "127.0.0.1:PORT_N/test" is tainted
+01:01:01 p1235  processing ACL check_recipient "accept" (TESTSUITE/test-config 54)
 01:01:01 p1235  check domains = +local_domains
 01:01:01 p1235  d in "+local_domains"?
 01:01:01 p1235   list element: +local_domains
@@ -709,7 +711,7 @@ close MYSQL connection: 127.0.0.1:PORT_N/test/root
 01:01:01 p1235    end sublist local_domains
 01:01:01 p1235  d in "+local_domains"? no (end of list)
 01:01:01 p1235  accept: condition test failed in ACL check_recipient
-01:01:01 p1235  processing ACL check_recipient "accept" (TESTSUITE/test-config 56)
+01:01:01 p1235  processing ACL check_recipient "accept" (TESTSUITE/test-config 57)
 01:01:01 p1235  check hosts = +relay_hosts
 01:01:01 p1235  host in "+relay_hosts"?
 01:01:01 p1235   list element: +relay_hosts
@@ -741,7 +743,7 @@ close MYSQL connection: 127.0.0.1:PORT_N/test/root
 01:01:01 p1235    end sublist relay_hosts
 01:01:01 p1235  host in "+relay_hosts"? no (end of list)
 01:01:01 p1235  accept: condition test failed in ACL check_recipient
-01:01:01 p1235  processing ACL check_recipient "deny" (TESTSUITE/test-config 57)
+01:01:01 p1235  processing ACL check_recipient "deny" (TESTSUITE/test-config 58)
 01:01:01 p1235    message: relay not permitted
 01:01:01 p1235  deny: condition test succeeded in ACL check_recipient
 01:01:01 p1235  end of ACL check_recipient: DENY
@@ -797,7 +799,7 @@ P Received: from CALLER by myhost.test.ex with local (Exim x.yz)
     for ph10@???;
     Tue, 2 Mar 1999 09:44:33 +0000
 using ACL "check_notsmtp"
-processing ACL check_notsmtp "accept" (TESTSUITE/test-config 60)
+processing ACL check_notsmtp "accept" (TESTSUITE/test-config 61)
 check set acl_m_qtest = ${quote_mysql:$recipients}
                       = ph10@???
 accept: condition test succeeded in ACL check_notsmtp
@@ -874,7 +876,7 @@ processing address_data
                               (tainted)
 No quoter name for addr
 LOG: MAIN PANIC
-  tainted search query is not properly quoted (router r1, TESTSUITE/test-config 68): select name from them where id='ph10' limit 1
+  tainted search query is not properly quoted (router r1, TESTSUITE/test-config 69): select name from them where id='ph10' limit 1
  required_quoter_id (mysql) quoting -1 (NULL)
  MySQL query: "select name from them where id='ph10' limit 1" opts 'NULL'
  MYSQL using cached connection for 127.0.0.1:PORT_N/test/root
@@ -919,7 +921,7 @@ appendfile transport entered
                               (tainted)
 No quoter name for addr
 LOG: MAIN
-  tainted search query is not properly quoted (transport t1, TESTSUITE/test-config 82): select id from them where id='ph10'
+  tainted search query is not properly quoted (transport t1, TESTSUITE/test-config 83): select id from them where id='ph10'
  required_quoter_id (mysql) quoting -1 (NULL)
  MySQL query: "select id from them where id='ph10'" opts 'NULL'
  MYSQL new connection: host=127.0.0.1 port=PORT_N socket=NULL database=test user=root
diff --git a/test/stderr/2620 b/test/stderr/2620
index fe7459356..05f2f445e 100644
--- a/test/stderr/2620
+++ b/test/stderr/2620
@@ -423,7 +423,8 @@ warn: condition test failed in ACL check_recipient
 processing ACL check_recipient "warn" (TESTSUITE/test-config 44)
 check set acl_m0 = FAIL: hostlist
 check hosts = <& net-pgsql;servers=localhost::PORT_N/test/CALLER/; select * from them where id='${quote_pgsql:$local_part}'
-host in "<& net-pgsql;servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"?
+list separator: '&'
+host in " net-pgsql;servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"?
  list element: net-pgsql;servers=localhost::PORT_N/test/CALLER/;░select░*░from░them░where░id='c'
  search_open: pgsql "NULL"
    cached open
@@ -439,14 +440,15 @@ LOG: MAIN
   Exim configuration error in ACL verb at line 44 of TESTSUITE/test-config:
   WARNING: obsolete syntax used for lookup
  lookup deferred: PostgreSQL server "localhost:PORT_N/test" is tainted
-host in "<& net-pgsql;servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"? list match deferred for net-pgsql;servers=localhost::1223/test/CALLER/; select * from them where id='c'
+host in " net-pgsql;servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"? list match deferred for net-pgsql;servers=localhost::1223/test/CALLER/; select * from them where id='c'
 warn: condition test deferred in ACL check_recipient
 LOG: MAIN
   H=(test) [10.0.0.0] Warning: ACL 'warn' statement skipped (in ACL check_recipient at line 44 of TESTSUITE/test-config): condition test deferred: PostgreSQL server "localhost:PORT_N/test" is tainted
 processing ACL check_recipient "warn" (TESTSUITE/test-config 49)
 check set acl_m0 = FAIL: hostlist
 check hosts = <& net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them where id='${quote_pgsql:$local_part}'
-host in "<& net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"?
+list separator: '&'
+host in " net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"?
  list element: net-pgsql,servers=localhost::PORT_N/test/CALLER/;░select░*░from░them░where░id='c'
  search_open: pgsql "NULL"
    cached open
@@ -459,7 +461,7 @@ host in "<& net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them
                               (tainted, quoted:pgsql)
  PostgreSQL query: " select * from them where id='c'" opts 'servers=localhost::PORT_N/test/CALLER/'
  lookup deferred: PostgreSQL server "localhost:PORT_N/test" is tainted
-host in "<& net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"? list match deferred for net-pgsql,servers=localhost::1223/test/CALLER/; select * from them where id='c'
+host in " net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"? list match deferred for net-pgsql,servers=localhost::1223/test/CALLER/; select * from them where id='c'
 warn: condition test deferred in ACL check_recipient
 LOG: MAIN
   H=(test) [10.0.0.0] Warning: ACL 'warn' statement skipped (in ACL check_recipient at line 49 of TESTSUITE/test-config): condition test deferred: PostgreSQL server "localhost:PORT_N/test" is tainted
@@ -614,7 +616,8 @@ warn: condition test failed in ACL check_recipient
 processing ACL check_recipient "warn" (TESTSUITE/test-config 44)
 check set acl_m0 = FAIL: hostlist
 check hosts = <& net-pgsql;servers=localhost::PORT_N/test/CALLER/; select * from them where id='${quote_pgsql:$local_part}'
-host in "<& net-pgsql;servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"?
+list separator: '&'
+host in " net-pgsql;servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"?
  list element: net-pgsql;servers=localhost::PORT_N/test/CALLER/;░select░*░from░them░where░id='c'
  search_open: pgsql "NULL"
    cached open
@@ -630,14 +633,15 @@ LOG: MAIN
   Exim configuration error in ACL verb at line 44 of TESTSUITE/test-config:
   WARNING: obsolete syntax used for lookup
  lookup deferred: PostgreSQL server "localhost:PORT_N/test" is tainted
-host in "<& net-pgsql;servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"? list match deferred for net-pgsql;servers=localhost::1223/test/CALLER/; select * from them where id='c'
+host in " net-pgsql;servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"? list match deferred for net-pgsql;servers=localhost::1223/test/CALLER/; select * from them where id='c'
 warn: condition test deferred in ACL check_recipient
 LOG: MAIN
   H=(test) [10.0.0.0] Warning: ACL 'warn' statement skipped (in ACL check_recipient at line 44 of TESTSUITE/test-config): condition test deferred: PostgreSQL server "localhost:PORT_N/test" is tainted
 processing ACL check_recipient "warn" (TESTSUITE/test-config 49)
 check set acl_m0 = FAIL: hostlist
 check hosts = <& net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them where id='${quote_pgsql:$local_part}'
-host in "<& net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"?
+list separator: '&'
+host in " net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"?
  list element: net-pgsql,servers=localhost::PORT_N/test/CALLER/;░select░*░from░them░where░id='c'
  search_open: pgsql "NULL"
    cached open
@@ -650,7 +654,7 @@ host in "<& net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them
                               (tainted, quoted:pgsql)
  PostgreSQL query: " select * from them where id='c'" opts 'servers=localhost::PORT_N/test/CALLER/'
  lookup deferred: PostgreSQL server "localhost:PORT_N/test" is tainted
-host in "<& net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"? list match deferred for net-pgsql,servers=localhost::1223/test/CALLER/; select * from them where id='c'
+host in " net-pgsql,servers=localhost::PORT_N/test/CALLER/; select * from them where id='c'"? list match deferred for net-pgsql,servers=localhost::1223/test/CALLER/; select * from them where id='c'
 warn: condition test deferred in ACL check_recipient
 LOG: MAIN
   H=(test) [10.0.0.0] Warning: ACL 'warn' statement skipped (in ACL check_recipient at line 49 of TESTSUITE/test-config): condition test deferred: PostgreSQL server "localhost:PORT_N/test" is tainted
diff --git a/test/stdout/0002 b/test/stdout/0002
index 0524afd7b..abeb2329f 100644
--- a/test/stdout/0002
+++ b/test/stdout/0002
@@ -1099,5 +1099,6 @@ xyz

> in list
> in list
>

-> '${listextract {2} {<. my.target.host.name}}' => 'target'
+> variable m0 set
+> '${listextract {2} {my:target:string:list}}' => 'target'
>


--
## subscription configuration (requires account):
## https://lists.exim.org/mailman3/postorius/lists/exim-cvs.lists.exim.org/
## unsubscribe (doesn't require an account):
## exim-cvs-unsubscribe@???
## Exim details at http://www.exim.org/
## Please use the Wiki with this list - http://wiki.exim.org/