You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ds...@apache.org on 2026/09/25 02:32:16 UTC

(solr) 02/05: SOLR-18224: New allowZkHosts setting for cross-collection join/streaming zkHost

This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit 4544231a09a17fc5a1711169a3565e082108c33f
Author: Mark Robert Miller <ma...@apache.org>
AuthorDate: Mon Sep 21 21:02:24 2026 -0400

    SOLR-18224: New allowZkHosts setting for cross-collection join/streaming zkHost
    
    Add a solr.xml setting, allowZkHosts, that limits which ZooKeeper
    connection strings a cross-collection join or streaming expression may
    use (zkHost or solrConnection); an HTTP(s) solrConnection must be a
    live node or be listed in allowUrls. The local SolrCloud ensemble is
    always allowed. zkHost and solrUrl are now mutually exclusive in a
    single cross-collection join clause.
    
    Behavior change: the local-ensemble match is verbatim, including any
    chroot: a node started with -DzkHost=zk1:2181/solr allows
    zkHost="zk1:2181/solr" but not zkHost="zk1:2181" unless listed in
    allowZkHosts.
    
    Co-Authored-By: David Smiley <ds...@apache.org>
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    (cherry picked from commit b66758a1d03bc4c563d16b588d4be5c775ae5442)
    (cherry picked from commit f619a3df21ee9869f3af749b6c821431c5adfef5)
---
 changelog/unreleased/SOLR-18224-allowZkHosts.yml   |  14 +++
 .../java/org/apache/solr/cloud/ZkController.java   |  12 ++
 .../java/org/apache/solr/core/CoreContainer.java   |  20 ++++
 .../src/java/org/apache/solr/core/NodeConfig.java  |  20 ++++
 .../java/org/apache/solr/core/SolrXmlConfig.java   |  17 +++
 .../search/join/CrossCollectionJoinQParser.java    |  15 +++
 .../solr/security/AllowListZkHostChecker.java      |  76 ++++++++++++
 .../src/test/org/apache/solr/core/TestSolrXml.java |  19 +++
 .../search/join/CrossCollectionJoinQueryTest.java  | 131 ++++++++++++++++++++-
 solr/server/solr/solr.xml                          |   1 +
 .../pages/configuring-solr-xml.adoc                |  19 +++
 .../query-guide/pages/join-query-parser.adoc       |  14 ++-
 .../pages/major-changes-in-solr-9.adoc             |  11 ++
 .../solr/client/solrj/io/SolrClientCache.java      |  33 ++++++
 .../solr/client/solrj/io/stream/TupleStream.java   |   2 +
 15 files changed, 402 insertions(+), 2 deletions(-)

diff --git a/changelog/unreleased/SOLR-18224-allowZkHosts.yml b/changelog/unreleased/SOLR-18224-allowZkHosts.yml
new file mode 100644
index 00000000000..ade1942d695
--- /dev/null
+++ b/changelog/unreleased/SOLR-18224-allowZkHosts.yml
@@ -0,0 +1,14 @@
+title: >-
+  New solr.xml setting allowZkHosts limits which ZooKeeper connection strings
+  a cross-collection join or streaming expression may use (zkHost). The local
+  SolrCloud ensemble is always allowed. zkHost and solrUrl are now mutually
+  exclusive in a single cross-collection join clause. The local ensemble is
+  matched verbatim, including any chroot: a node started with
+  `-DzkHost=zk1:2181/solr` allows `zkHost="zk1:2181/solr"` but not
+  `zkHost="zk1:2181"` unless listed in `allowZkHosts`.
+type: changed
+authors:
+  - name: Mark Robert Miller
+links:
+  - name: SOLR-18224
+    url: https://issues.apache.org/jira/browse/SOLR-18224
diff --git a/solr/core/src/java/org/apache/solr/cloud/ZkController.java b/solr/core/src/java/org/apache/solr/cloud/ZkController.java
index 26ba27b82e3..72467e0fc51 100644
--- a/solr/core/src/java/org/apache/solr/cloud/ZkController.java
+++ b/solr/core/src/java/org/apache/solr/cloud/ZkController.java
@@ -134,6 +134,7 @@ import org.apache.solr.logging.MDCLoggingContext;
 import org.apache.solr.request.LocalSolrQueryRequest;
 import org.apache.solr.response.SolrQueryResponse;
 import org.apache.solr.search.SolrIndexSearcher;
+import org.apache.solr.security.AllowListZkHostChecker;
 import org.apache.solr.update.UpdateLog;
 import org.apache.solr.util.AddressUtils;
 import org.apache.solr.util.RTimer;
@@ -223,6 +224,7 @@ public class ZkController implements Closeable {
   private CloudHttp2SolrClient cloudSolrClient;
 
   private final String zkServerAddress; // example: 127.0.0.1:54062/solr
+  private final AllowListZkHostChecker allowListZkHostChecker;
 
   private final int localHostPort; // example: 54065
   private final String hostName; // example: 127.0.0.1
@@ -341,6 +343,7 @@ public class ZkController implements Closeable {
     String localHostContext = trimLeadingAndTrailingSlashes(cloudConfig.getSolrHostContext());
 
     this.zkServerAddress = zkServerAddress;
+    this.allowListZkHostChecker = AllowListZkHostChecker.create(cc.getConfig(), zkServerAddress);
     this.localHostPort = cloudConfig.getSolrHostPort();
     this.hostName = normalizeHostName(cloudConfig.getHost());
     this.nodeName =
@@ -959,6 +962,15 @@ public class ZkController implements Closeable {
     return zkServerAddress;
   }
 
+  /**
+   * Returns the ZooKeeper-host checker based on the {@code allowZkHosts} configuration in {@code
+   * solr.xml}, always allowing this cluster's ZK ensemble. Used by features that accept a
+   * caller-supplied {@code zkHost}.
+   */
+  public AllowListZkHostChecker getAllowListZkHostChecker() {
+    return allowListZkHostChecker;
+  }
+
   boolean isClosed() {
     return isClosed;
   }
diff --git a/solr/core/src/java/org/apache/solr/core/CoreContainer.java b/solr/core/src/java/org/apache/solr/core/CoreContainer.java
index 776219293f2..7cfb713939f 100644
--- a/solr/core/src/java/org/apache/solr/core/CoreContainer.java
+++ b/solr/core/src/java/org/apache/solr/core/CoreContainer.java
@@ -853,6 +853,8 @@ public class CoreContainer {
         new HttpSolrClientProvider(cfg.getUpdateShardHandlerConfig(), solrMetricsContext);
     updateShardHandler.initializeMetrics(solrMetricsContext, "updateShardHandler");
     solrClientCache = new SolrClientCache(solrClientProvider.getSolrClient());
+    // Validate caller-supplied zkHost (cross-collection join, streaming expressions).
+    solrClientCache.setZkHostValidator(this::validateZkHost);
 
     Map<String, CacheConfig> cachesConfig = cfg.getCachesConfig();
     if (cachesConfig.isEmpty()) {
@@ -1715,6 +1717,24 @@ public class CoreContainer {
     return allowListUrlChecker;
   }
 
+  /**
+   * Validates a caller-supplied ZooKeeper connection string via {@link
+   * ZkController#getAllowListZkHostChecker()}. Never allowed in standalone mode.
+   *
+   * @throws SolrException FORBIDDEN if not allowed
+   */
+  private void validateZkHost(String zkHost) {
+    ZkController zkController = getZkController();
+    if (zkController == null) {
+      throw new SolrException(
+          ErrorCode.FORBIDDEN,
+          "ZooKeeper host '" + zkHost + "' is not allowed when Solr is not in SolrCloud mode.");
+    }
+    if (!zkController.getAllowListZkHostChecker().isAllowed(zkHost)) {
+      throw new SolrException(ErrorCode.FORBIDDEN, SolrClientCache.zkHostRejectionMessage(zkHost));
+    }
+  }
+
   /**
    * Creates a new core based on a CoreDescriptor.
    *
diff --git a/solr/core/src/java/org/apache/solr/core/NodeConfig.java b/solr/core/src/java/org/apache/solr/core/NodeConfig.java
index 67c71839436..33662771dfd 100644
--- a/solr/core/src/java/org/apache/solr/core/NodeConfig.java
+++ b/solr/core/src/java/org/apache/solr/core/NodeConfig.java
@@ -130,6 +130,8 @@ public class NodeConfig {
   private final boolean fromZookeeper;
   private final String defaultZkHost;
 
+  private final List<String> allowZkHosts;
+
   private NodeConfig(
       String nodeName,
       Path coreRootDirectory,
@@ -167,6 +169,7 @@ public class NodeConfig {
       String defaultZkHost,
       Set<Path> allowPaths,
       List<String> allowUrls,
+      List<String> allowZkHosts,
       boolean hideStackTraces,
       String configSetServiceClass,
       String modules,
@@ -208,6 +211,7 @@ public class NodeConfig {
     this.defaultZkHost = defaultZkHost;
     this.allowPaths = allowPaths;
     this.allowUrls = allowUrls;
+    this.allowZkHosts = allowZkHosts;
     this.hideStackTraces = hideStackTraces;
     this.configSetServiceClass = configSetServiceClass;
     this.modules = modules;
@@ -499,6 +503,15 @@ public class NodeConfig {
     return allowUrls;
   }
 
+  /**
+   * Allow-list of remote ZooKeeper connection strings that may be passed to query/streaming
+   * features which accept a {@code zkHost} parameter (e.g. cross-collection join, /stream
+   * expressions). The local cluster's own ZK ensemble is always trusted and need not be listed.
+   */
+  public List<String> getAllowZkHosts() {
+    return allowZkHosts;
+  }
+
   public boolean hideStackTraces() {
     return hideStackTraces;
   }
@@ -670,6 +683,7 @@ public class NodeConfig {
     private String defaultZkHost;
     private Set<Path> allowPaths = Collections.emptySet();
     private List<String> allowUrls = Collections.emptyList();
+    private List<String> allowZkHosts = Collections.emptyList();
     private boolean hideStackTrace = Boolean.getBoolean("solr.hideStackTrace");
 
     private final Path solrHome;
@@ -891,6 +905,11 @@ public class NodeConfig {
       return this;
     }
 
+    public NodeConfigBuilder setAllowZkHosts(List<String> zkHosts) {
+      this.allowZkHosts = zkHosts;
+      return this;
+    }
+
     public NodeConfigBuilder setHideStackTrace(boolean hide) {
       this.hideStackTrace = hide;
       return this;
@@ -993,6 +1012,7 @@ public class NodeConfig {
           defaultZkHost,
           allowPaths,
           allowUrls,
+          allowZkHosts,
           hideStackTrace,
           configSetServiceClass,
           modules,
diff --git a/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java b/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java
index 6bb5d6e8427..d6ae23aae2a 100644
--- a/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java
+++ b/solr/core/src/java/org/apache/solr/core/SolrXmlConfig.java
@@ -408,6 +408,9 @@ public class SolrXmlConfig {
               case "allowUrls":
                 builder.setAllowUrls(separateStrings(it.txt()));
                 break;
+              case "allowZkHosts":
+                builder.setAllowZkHosts(separateZkHosts(it.txt()));
+                break;
               default:
                 throw new SolrException(
                     SolrException.ErrorCode.SERVER_ERROR,
@@ -437,6 +440,20 @@ public class SolrXmlConfig {
     return Set.of(COMMA_SEPARATED_PATTERN.split(commaSeparatedString));
   }
 
+  /**
+   * Like {@link #separateStrings(String)} but drops blank entries so a stray comma or unset {@code
+   * ${prop:}} placeholder does not produce an empty allow-list entry. {@code allowUrls}/{@code
+   * allowPaths} keep their pre-existing behavior.
+   */
+  private static List<String> separateZkHosts(String commaSeparatedString) {
+    if (StrUtils.isNullOrEmpty(commaSeparatedString)) {
+      return Collections.emptyList();
+    }
+    return Arrays.stream(COMMA_SEPARATED_PATTERN.split(commaSeparatedString))
+        .filter(s -> !s.isBlank())
+        .collect(Collectors.toList());
+  }
+
   private static Set<Path> separatePaths(String commaSeparatedString) {
     if (StrUtils.isNullOrEmpty(commaSeparatedString)) {
       return Collections.emptySet();
diff --git a/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java b/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java
index 2e4675a880d..40c69412741 100644
--- a/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java
+++ b/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java
@@ -22,9 +22,11 @@ import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Set;
 import org.apache.lucene.search.Query;
+import org.apache.solr.common.SolrException;
 import org.apache.solr.common.params.ModifiableSolrParams;
 import org.apache.solr.common.params.ShardParams;
 import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.core.CoreContainer;
 import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.search.QParser;
 import org.apache.solr.search.QueryParsing;
@@ -77,6 +79,11 @@ public class CrossCollectionJoinQParser extends QParser {
     String query = localParams.get(QueryParsing.V);
     String zkHost = localParams.get(ZK_HOST);
     String solrUrl = localParams.get(SOLR_URL);
+    // CrossCollectionJoinQuery.getDocSet() silently prefers zkHost when both are set.
+    if (zkHost != null && solrUrl != null) {
+      throw new SyntaxError(
+          "zkHost and solrUrl are mutually exclusive; specify at most one of them.");
+    }
     // Test if this is a valid solr url.
     if (solrUrl != null) {
       if (allowSolrUrls == null) {
@@ -109,6 +116,14 @@ public class CrossCollectionJoinQParser extends QParser {
       otherParams.set(ShardParams.SHARDS_PREFERENCE, shardsPreference);
     }
 
+    CoreContainer cc = req.getCoreContainer();
+    if (zkHost != null && cc != null) { // no CoreContainer -- unit test
+      try {
+        cc.getSolrClientCache().validateZkHost(zkHost);
+      } catch (SolrException e) {
+        throw new SyntaxError(e.getMessage(), e);
+      }
+    }
     return new CrossCollectionJoinQuery(
         query, zkHost, solrUrl, collection, fromField, toField, routedByJoinKey, ttl, otherParams);
   }
diff --git a/solr/core/src/java/org/apache/solr/security/AllowListZkHostChecker.java b/solr/core/src/java/org/apache/solr/security/AllowListZkHostChecker.java
new file mode 100644
index 00000000000..c6ec76f7f4c
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/security/AllowListZkHostChecker.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.security;
+
+import java.util.Collection;
+import java.util.Set;
+import org.apache.solr.core.NodeConfig;
+
+/**
+ * Validates a caller-supplied ZooKeeper connection string against the local cluster's ensemble and
+ * a configured allow-list. The ZooKeeper counterpart of {@link AllowListUrlChecker}.
+ *
+ * <p>A {@code zkHost} value is allowed if any of the following are true:
+ *
+ * <ul>
+ *   <li>It is {@code null} (caller did not specify one; Solr will use the local ensemble).
+ *   <li>It equals the local SolrCloud ZooKeeper connection string verbatim.
+ *   <li>It appears verbatim in the {@code allowZkHosts} list configured in {@code solr.xml}.
+ * </ul>
+ *
+ * <p>Comparison is strict character-for-character; no normalization of trailing slashes, chroot
+ * suffixes, host order, whitespace, or case. Operators must list each form they intend to accept.
+ */
+public class AllowListZkHostChecker {
+
+  /** {@link org.apache.solr.core.SolrXmlConfig} property to configure the allowed ZK hosts. */
+  public static final String ZK_HOST_ALLOW_LIST = "allowZkHosts";
+
+  private final Set<String> allowedZkHosts;
+  private final String localZkHost;
+
+  /**
+   * @param allowedZkHosts the verbatim ZooKeeper connection strings configured under {@link
+   *     #ZK_HOST_ALLOW_LIST} in {@code solr.xml}. May be null or empty.
+   * @param localZkHost the local SolrCloud ZooKeeper connection string. May be null.
+   */
+  public AllowListZkHostChecker(Collection<String> allowedZkHosts, String localZkHost) {
+    this.allowedZkHosts =
+        allowedZkHosts == null || allowedZkHosts.isEmpty() ? Set.of() : Set.copyOf(allowedZkHosts);
+    this.localZkHost = localZkHost;
+  }
+
+  /** Creates a checker from the {@link NodeConfig} and the local ZK address. */
+  public static AllowListZkHostChecker create(NodeConfig config, String localZkHost) {
+    return new AllowListZkHostChecker(config.getAllowZkHosts(), localZkHost);
+  }
+
+  /**
+   * Returns true if {@code zkHost} is null, equals the local ensemble, or appears verbatim in the
+   * configured allow-list.
+   */
+  public boolean isAllowed(String zkHost) {
+    if (zkHost == null) {
+      return true;
+    }
+    if (zkHost.equals(localZkHost)) {
+      return true;
+    }
+    return allowedZkHosts.contains(zkHost);
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/core/TestSolrXml.java b/solr/core/src/test/org/apache/solr/core/TestSolrXml.java
index 5a6bc4c5ba1..502f4b653b6 100644
--- a/solr/core/src/test/org/apache/solr/core/TestSolrXml.java
+++ b/solr/core/src/test/org/apache/solr/core/TestSolrXml.java
@@ -22,6 +22,7 @@ import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Properties;
@@ -210,6 +211,24 @@ public class TestSolrXml extends SolrTestCaseJ4 {
     assertEquals("hostPort", 8000, cfg.getCloudConfig().getSolrHostPort());
   }
 
+  // SOLR-18224: allowZkHosts is parsed via a helper that drops blank entries so a
+  // stray comma or unset ${prop:} expansion cannot produce an empty allow-list entry.
+  public void testAllowZkHostsParsing() {
+    String withEntries =
+        "<solr><str name=\"allowZkHosts\">zk-a:2181,,zk-b:2181/chroot, ,zk-c:2181</str></solr>";
+    NodeConfig cfgEntries = SolrXmlConfig.fromString(solrHome, withEntries);
+    assertEquals(
+        List.of("zk-a:2181", "zk-b:2181/chroot", "zk-c:2181"), cfgEntries.getAllowZkHosts());
+
+    String empty = "<solr><str name=\"allowZkHosts\"></str></solr>";
+    NodeConfig cfgEmpty = SolrXmlConfig.fromString(solrHome, empty);
+    assertEquals(List.of(), cfgEmpty.getAllowZkHosts());
+
+    String absent = "<solr></solr>";
+    NodeConfig cfgAbsent = SolrXmlConfig.fromString(solrHome, absent);
+    assertEquals(List.of(), cfgAbsent.getAllowZkHosts());
+  }
+
   public void testIntAsLongBad() {
     String bad = "" + TestUtil.nextLong(random(), Integer.MAX_VALUE, Long.MAX_VALUE);
     String solrXml =
diff --git a/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java b/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java
index 913722cccba..d1d69409172 100644
--- a/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java
+++ b/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java
@@ -40,6 +40,7 @@ import org.apache.solr.embedded.JettySolrRunner;
 import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.request.SolrQueryRequestBase;
 import org.apache.solr.search.QueryParsing;
+import org.apache.solr.search.SyntaxError;
 import org.junit.BeforeClass;
 import org.junit.Ignore;
 import org.junit.Test;
@@ -163,6 +164,7 @@ public class CrossCollectionJoinQueryTest extends SolrCloudTestCase {
       String url = runner.getBaseUrl().toString();
       System.setProperty("test.ccjoin.solr.url." + i, url);
     }
+    String quorumHosts = cluster.getSolrClient().getClusterStateProvider().getQuorumHosts();
     try {
       // now we need to re-upload our config , now that we know a valid solr url for the cluster.
       CloudSolrClient client = cluster.getSolrClient();
@@ -202,11 +204,12 @@ public class CrossCollectionJoinQueryTest extends SolrCloudTestCase {
               "{!join method=crossCollection solrUrl=\"%s\" fromIndex=products from=product_id_s to=product_id_s}size_s:M",
               getSolrUrl()),
           true);
+      // The local cluster's ZK ensemble is always allowed; no allowZkHosts entry needed.
       testCcJoinQuery(
           String.format(
               Locale.ROOT,
               "{!join method=crossCollection zkHost=\"%s\" fromIndex=products from=product_id_s to=product_id_s}size_s:M",
-              client.getClusterStateProvider().getQuorumHosts()),
+              quorumHosts),
           true);
 
       // Test the ability to set other parameters on crossCollection join and have them passed
@@ -316,6 +319,132 @@ public class CrossCollectionJoinQueryTest extends SolrCloudTestCase {
     }
   }
 
+  @Test
+  public void testAllowZkHostsList() throws Exception {
+    setupIndexes(false);
+
+    int i = 0;
+    for (JettySolrRunner runner : cluster.getJettySolrRunners()) {
+      i++;
+      System.setProperty("test.ccjoin.solr.url." + i, runner.getBaseUrl().toString());
+    }
+    String quorumHosts = cluster.getSolrClient().getClusterStateProvider().getQuorumHosts();
+    try {
+      CloudSolrClient client = cluster.getSolrClient();
+      cluster.uploadConfigSet(configset("ccjoin"), "ccjoin");
+      CollectionAdminRequest.Reload.reloadCollection("products").process(client);
+      CollectionAdminRequest.Reload.reloadCollection("parts").process(client);
+
+      Exception unlisted =
+          expectThrows(
+              Exception.class,
+              () ->
+                  testCcJoinQuery(
+                      "{!join method=crossCollection zkHost=\"other-zk.example.com:2181/solr\""
+                          + " fromIndex=products from=product_id_i to=product_id_i}size_s:M",
+                      true));
+      assertTrue(
+          "Unexpected message: " + unlisted.getMessage(),
+          unlisted.getMessage().contains("other-zk.example.com:2181/solr"));
+
+      // The local cluster's ZK ensemble is always allowed, so the local quorum string must
+      // succeed. product_id_i (not _s) so the join does not default to routed-by-join-key --
+      // setupIndexes(false) does not shard-route by that field.
+      testCcJoinQuery(
+          String.format(
+              Locale.ROOT,
+              "{!join method=crossCollection zkHost=\"%s\""
+                  + " fromIndex=products from=product_id_i to=product_id_i}size_s:M",
+              quorumHosts),
+          true);
+
+      Exception both =
+          expectThrows(
+              Exception.class,
+              () ->
+                  testCcJoinQuery(
+                      String.format(
+                          Locale.ROOT,
+                          "{!join method=crossCollection zkHost=\"%s\" solrUrl=\"%s\""
+                              + " fromIndex=products from=product_id_i to=product_id_i}size_s:M",
+                          quorumHosts,
+                          getSolrUrl()),
+                      true));
+      assertTrue(
+          "Unexpected message: " + both.getMessage(),
+          both.getMessage().contains("mutually exclusive"));
+
+      // Mutual exclusion is checked before either allow-list.
+      Exception bothUnlistedZk =
+          expectThrows(
+              Exception.class,
+              () ->
+                  testCcJoinQuery(
+                      String.format(
+                          Locale.ROOT,
+                          "{!join method=crossCollection"
+                              + " zkHost=\"other-zk.example.com:2181/solr\" solrUrl=\"%s\""
+                              + " fromIndex=products from=product_id_i to=product_id_i}size_s:M",
+                          getSolrUrl()),
+                      true));
+      assertTrue(
+          "Unexpected message: " + bothUnlistedZk.getMessage(),
+          bothUnlistedZk.getMessage().contains("mutually exclusive"));
+
+      Exception bothUnlistedUrl =
+          expectThrows(
+              Exception.class,
+              () ->
+                  testCcJoinQuery(
+                      String.format(
+                          Locale.ROOT,
+                          "{!join method=crossCollection zkHost=\"%s\""
+                              + " solrUrl=\"http://other-solr.example.com:18080/solr\""
+                              + " fromIndex=products from=product_id_i to=product_id_i}size_s:M",
+                          quorumHosts),
+                      true));
+      assertTrue(
+          "Unexpected message: " + bothUnlistedUrl.getMessage(),
+          bothUnlistedUrl.getMessage().contains("mutually exclusive"));
+
+    } finally {
+      int j = 0;
+      for (JettySolrRunner runner : cluster.getJettySolrRunners()) {
+        j++;
+        System.getProperties().remove("test.ccjoin.solr.url." + j);
+      }
+    }
+  }
+
+  @Test
+  public void testZkHostAndSolrUrlMutualExclusionAtParser() throws SyntaxError {
+    ModifiableSolrParams localParams = new ModifiableSolrParams();
+    localParams.set(QueryParsing.V, "*:*");
+    localParams.set(CrossCollectionJoinQParser.ZK_HOST, "trusted-zk:2181/chroot");
+    localParams.set(CrossCollectionJoinQParser.SOLR_URL, "http://trusted-solr:8983/solr");
+    localParams.set(CrossCollectionJoinQParser.FROM_INDEX, "products");
+    localParams.set(CrossCollectionJoinQParser.FROM, "product_id_s");
+    localParams.set(CrossCollectionJoinQParser.TO, "product_id_s");
+    localParams.set(CrossCollectionJoinQParser.ROUTED_BY_JOIN_KEY, "false");
+
+    ModifiableSolrParams requestParams = new ModifiableSolrParams();
+    try (SolrQueryRequest req = new SolrQueryRequestBase(null, requestParams) {}) {
+      CrossCollectionJoinQParser parser =
+          new CrossCollectionJoinQParser(
+              null, localParams, requestParams, req, "product_id_s", null);
+      SyntaxError e =
+          expectThrows(
+              SyntaxError.class,
+              "Expected SyntaxError when both zkHost and solrUrl are specified",
+              parser::parse);
+      assertTrue(
+          "Unexpected message: " + e.getMessage(),
+          e.getMessage().contains("mutually exclusive")
+              && e.getMessage().contains("zkHost")
+              && e.getMessage().contains("solrUrl"));
+    }
+  }
+
   @Test
   public void testShardsPreferenceRequestParamPropagation() throws Exception {
     // shards.preference set as a request-level param (not in localParams) should be
diff --git a/solr/server/solr/solr.xml b/solr/server/solr/solr.xml
index 963345095b3..2bc43cea404 100644
--- a/solr/server/solr/solr.xml
+++ b/solr/server/solr/solr.xml
@@ -33,6 +33,7 @@
   <str name="modules">${solr.modules:}</str>
   <str name="allowPaths">${solr.allowPaths:}</str>
   <str name="allowUrls">${solr.allowUrls:}</str>
+  <str name="allowZkHosts">${solr.allowZkHosts:}</str>
   <str name="hideStackTrace">${solr.hideStackTrace:false}</str>
   <int name="indexSearcherExecutorThreads">${solr.searchThreads:0}</int>
 
diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc b/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc
index a205dc7552f..b970b6db439 100644
--- a/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc
+++ b/solr/solr-ref-guide/modules/configuration-guide/pages/configuring-solr-xml.adoc
@@ -39,6 +39,7 @@ The default `solr.xml` file is found in `$SOLR_TIP/server/solr/solr.xml` and loo
   <str name="modules">${solr.modules:}</str>
   <str name="allowPaths">${solr.allowPaths:}</str>
   <str name="allowUrls">${solr.allowUrls:}</str>
+  <str name="allowZkHosts">${solr.allowZkHosts:}</str>
   <str name="hideStackTrace">${solr.hideStackTrace:false}</str>
 
   <solrcloud>
@@ -291,6 +292,24 @@ In SolrCloud mode, the allow-list is automatically configured to include all liv
 The allow-list can also be configured with the `solr.allowUrls` system property in `solr.in.sh` / `solr.in.cmd`.
 If you need to disable this feature for backwards compatibility, you can set the system property `solr.disable.allowUrls=true`.
 
+[#allow-zk-hosts]
+`allowZkHosts`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Comma-separated list of ZooKeeper connection strings of other SolrCloud clusters that may be passed as a `zkHost` parameter to features that fetch data from another cluster, such as cross-collection join and streaming expressions.
+The local SolrCloud ZooKeeper ensemble is always allowed and need not be listed.
+Solr in standalone (non-SolrCloud) mode does not allow any `zkHost`.
++
+Matching is character-for-character against the full connection string -- e.g. `zk1:2181,zk2:2181,zk3:2181/solr` is a different value from `zk1:2181,zk2:2181,zk3:2181` or from the same hosts in a different order.
+The local ensemble's value includes any chroot suffix configured at startup: if Solr was started with `-DzkHost=zk1:2181/solr`, then `zkHost="zk1:2181/solr"` is allowed but `zkHost="zk1:2181"` is not unless listed.
+A value that is not allowed is rejected with an error mentioning `allowZkHosts`.
++
+The allow-list can also be configured with the `solr.allowZkHosts` system property in `solr.in.sh` / `solr.in.cmd`.
+
 `hideStackTrace`::
 +
 [%autowidth,frame=none]
diff --git a/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc b/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc
index 1b116c8c5a8..d7cf2dc8fc2 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/join-query-parser.adoc
@@ -285,6 +285,13 @@ This restriction is necessary to prevent an attacker from using Solr to explore
   </queryParser>
 ----
 
+[NOTE]
+====
+The `zkHost` parameter is governed by a node-level allow-list in `solr.xml` (`<str name="allowZkHosts">`), *not* by `solrconfig.xml`.
+The local SolrCloud ZooKeeper ensemble is always trusted -- only remote ensembles need to be listed.
+See <<Cross Collection Join Query Parameters>> below and the `solr.xml` reference for details.
+====
+
 === Cross Collection Join Query Parameters
 
 `fromIndex`::
@@ -304,8 +311,13 @@ The name of the external Solr collection to be queried to retrieve the set of jo
 |===
 +
 The connection string to be used to connect to ZooKeeper.
-`zkHost` and `solrUrl` are both optional parameters, and at most one of them should be specified.
+`zkHost` and `solrUrl` are both optional parameters, and at most one of them may be specified; passing both is rejected.
 If neither `zkHost` nor `solrUrl` are specified, the local ZooKeeper cluster will be used.
++
+An explicit `zkHost` value must either match the local SolrCloud ensemble verbatim or be listed verbatim in the node-level `<str name="allowZkHosts">` entry in `solr.xml`.
+The match is a character-for-character exact comparison of the full connection string -- e.g. `zk1:2181,zk2:2181,zk3:2181/solr` is a different value from `zk1:2181,zk2:2181,zk3:2181` or from the same hosts in a different order.
+The local value includes any chroot suffix configured at startup -- so for a node started with `-DzkHost=zk1:2181/solr`, `zkHost="zk1:2181/solr"` is allowed while `zkHost="zk1:2181"` is not.
+If the value does not match, the request is rejected with a `SyntaxError`.
 
 `solrUrl`::
 +
diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc
index 0a18962d1f1..367523493b9 100644
--- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc
+++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc
@@ -75,6 +75,17 @@ It is always strongly recommended that you fully reindex your documents after a
 In Solr 8, it was possible to add docValues to a schema without re-indexing via `UninvertDocValuesMergePolicy`, an advanced/expert utility.
 Due to changes in Lucene 9, that isn't possible any more.
 
+== Solr 9.12
+
+=== Connecting to Other Clusters
+
+Streaming expressions and the cross-collection join query parser now restrict which SolrCloud clusters a `zkHost` parameter may refer to:
+
+* A ZooKeeper connection string must match the local cluster's exactly (including any chroot), or be listed in the new xref:configuration-guide:configuring-solr-xml.adoc#allow-zk-hosts[`allowZkHosts`] setting in `solr.xml` (or the `solr.allowZkHosts` system property).
+* The cross-collection join query parser no longer accepts both `zkHost` and `solrUrl`; specify at most one.
+
+If you use these features to query another cluster, add its connection details to `allowZkHosts` before upgrading.
+
 == Solr 9.11
 
 === Max distributed requests now configurable
diff --git a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java
index 271cb1bc29f..95f065c289f 100644
--- a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java
+++ b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java
@@ -26,6 +26,7 @@ import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
 import org.apache.http.client.HttpClient;
 import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.impl.CloudHttp2SolrClient;
@@ -59,6 +60,7 @@ public class SolrClientCache implements Closeable {
   private final Http2SolrClient http2SolrClient;
   private final AtomicBoolean isClosed = new AtomicBoolean(false);
   private final AtomicReference<String> defaultZkHost = new AtomicReference<>();
+  private volatile Consumer<String> zkHostValidator;
 
   public SolrClientCache() {
     this.apacheHttpClient = null;
@@ -76,6 +78,35 @@ public class SolrClientCache implements Closeable {
     this.http2SolrClient = http2SolrClient;
   }
 
+  /**
+   * Installs a ZooKeeper host validator that {@link #getCloudSolrClient(String)} consults on every
+   * call, before any ZK contact. The validator rejects a zkHost by throwing, typically a {@code 403
+   * Forbidden} {@link org.apache.solr.common.SolrException}. A {@code null} validator disables
+   * validation.
+   *
+   * <p>The validator is invoked while holding the cache monitor, so it must be non-blocking and
+   * must not perform I/O.
+   */
+  public void setZkHostValidator(Consumer<String> zkHostValidator) {
+    this.zkHostValidator = zkHostValidator;
+  }
+
+  /** Throws if the installed validator rejects the zkHost. */
+  public void validateZkHost(String zkHost) {
+    var validator = zkHostValidator;
+    if (validator != null) {
+      validator.accept(zkHost);
+    }
+  }
+
+  /** Message for a rejected ZooKeeper connection string. */
+  public static String zkHostRejectionMessage(String connectionString) {
+    return "ZooKeeper host '"
+        + connectionString
+        + "' is not on the 'allowZkHosts' allow-list in solr.xml and does not match the local"
+        + " cluster's ZK ensemble.";
+  }
+
   public void setDefaultZKHost(String zkHost) {
     if (zkHost != null) {
       zkHost = zkHost.split("/")[0];
@@ -90,6 +121,8 @@ public class SolrClientCache implements Closeable {
   public synchronized CloudSolrClient getCloudSolrClient(String zkHost) {
     ensureOpen();
     Objects.requireNonNull(zkHost, "ZooKeeper host cannot be null!");
+    // before the cache lookup, so cached clients are also subject to the validator
+    validateZkHost(zkHost);
     if (solrClients.containsKey(zkHost)) {
       return (CloudSolrClient) solrClients.get(zkHost);
     }
diff --git a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/TupleStream.java b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/TupleStream.java
index 435d5984083..181acbb8471 100644
--- a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/TupleStream.java
+++ b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/TupleStream.java
@@ -143,6 +143,8 @@ public abstract class TupleStream implements Closeable, Serializable, MapWriter
         localSolrClientCache; // tracks any locally allocated cache that needs to be closed locally
     if (solrClientCache
         == null) { // streamContext was null OR streamContext.getSolrClientCache() returned null
+      // Note: this cache doesn't validate connections. Within Solr, callers (StreamHandler,
+      // CrossCollectionJoinQuery) supply CoreContainer's cache via the StreamContext.
       solrClientCache = localSolrClientCache = new SolrClientCache();
     } else {
       localSolrClientCache = null;