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;