From ab0ebb8d8b841a190ca965d9eebdc08877a387f4 Mon Sep 17 00:00:00 2001 From: David Smiley Date: Mon, 12 Oct 2026 23:45:12 +0100 Subject: [PATCH 01/11] SOLR-18537: Proxy requests to a node via X-Solr-targetNode Header or param. Useful to reach a specific node. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SOLR-18537-proxyRequestsByHeader.yml | 10 ++ .../solr/servlet/NodeTargetProxyFilter.java | 138 ++++++++++++++++++ solr/webapp/web/WEB-INF/web.xml | 9 ++ 3 files changed, 157 insertions(+) create mode 100644 changelog/unreleased/SOLR-18537-proxyRequestsByHeader.yml create mode 100644 solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java diff --git a/changelog/unreleased/SOLR-18537-proxyRequestsByHeader.yml b/changelog/unreleased/SOLR-18537-proxyRequestsByHeader.yml new file mode 100644 index 000000000000..0ccbe12242ca --- /dev/null +++ b/changelog/unreleased/SOLR-18537-proxyRequestsByHeader.yml @@ -0,0 +1,10 @@ +title: > + Any request can now be routed to a specific SolrCloud node by setting the `X-Solr-targetNode` header + (or, for GET requests, a query parameter of the same name). The receiving node proxies the request there. + This is useful for reaching a particular node behind a load balancer, including for node-level admin APIs. +type: added +authors: + - name: David Smiley +links: + - name: SOLR-18537 + url: https://issues.apache.org/jira/browse/SOLR-18537 diff --git a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java new file mode 100644 index 000000000000..65bc9d725e1d --- /dev/null +++ b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java @@ -0,0 +1,138 @@ +/* + * 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.servlet; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.UnavailableException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.cloud.ZkStateReader; +import org.apache.solr.core.CoreContainer; +import org.eclipse.jetty.client.HttpClient; + +/** + * If the request carries the {@link #TARGET_NODE_HEADER} header naming a different live node, + * proxies the request there verbatim and returns; otherwise passes the request through. Unlike + * {@link HttpSolrCall}'s collection/core-driven proxying, this works for any endpoint, including + * node-level ones (e.g. {@code /solr/admin/**}, {@code /api/node/**}), so a request can be pinned + * to a specific node even when reached through a load balancer that hides which node answers. + */ +public class NodeTargetProxyFilter extends CoreContainerAwareHttpFilter { + + /** + * Header naming a live node (as it appears in ZK live_nodes) to proxy this request to. Two + * shorthand forms are also accepted, both resolved relative to the current node: a value of all + * digits replaces the trailing {@code -} ordinal in the current node's hostname (e.g. + * {@code 8} turns {@code myhost-3.mydomain.com:3000} into {@code myhost-8.mydomain.com:3000}); + * otherwise, a value with no '.' is treated as a bare peer hostname, with the current node's + * domain suffix and port appended. + */ + public static final String TARGET_NODE_HEADER = "X-Solr-targetNode"; + + @Override + protected void doFilter(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) + throws IOException, ServletException { + // also accept it as a query param on GET requests, for convenience when a header can't + // easily be set (e.g. a browser address bar); skipped for other methods since + // getParameter() would consume a form-urlencoded POST body + String targetNode = req.getHeader(TARGET_NODE_HEADER); + if (targetNode == null + && "GET".equals(req.getMethod()) + && req.getQueryString() != null + && req.getQueryString().contains(TARGET_NODE_HEADER)) { + targetNode = req.getParameter(TARGET_NODE_HEADER); // will parse + } + if (targetNode == null || targetNode.isBlank() || !proxyToNode(targetNode, req, resp)) { + chain.doFilter(req, resp); + } + } + + /** Returns true if the request was proxied to {@code targetNode}. */ + private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServletResponse resp) { + CoreContainer cores; + try { + cores = getCores(); + } catch (UnavailableException e) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Core Container Unavailable"); + } + if (!cores.isZooKeeperAware()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, TARGET_NODE_HEADER + " header requires SolrCloud"); + } + String ownNodeName = cores.getZkController().getNodeName(); + String resolvedNode = resolvePeerShorthand(targetNode, ownNodeName); + ZkStateReader zkStateReader = cores.getZkController().getZkStateReader(); + if (!zkStateReader.getClusterState().getLiveNodes().contains(resolvedNode)) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, "Unknown or non-live node: " + resolvedNode); + } + if (resolvedNode.equals(ownNodeName)) { + return false; // already on the target node; let it process normally + } + + String url = + zkStateReader.getBaseUrlForNodeName(resolvedNode) + ServletUtils.getPathAfterContext(req); + String queryString = req.getQueryString(); + if (queryString != null) { + url = url + "?" + queryString; + } + try { + HttpClient httpClient = + ((HttpJettySolrClient) cores.getDefaultHttpSolrClient()).getHttpClient(); + HttpSolrProxy.doHttpProxy(httpClient, req, resp, url); + } catch (Exception e) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + "Error proxying request to node: " + resolvedNode, + e); + } catch (Error e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + return true; + } + + /** + * If {@code targetNode} is all digits, it's an ordinal shorthand: {@code ownNodeName}'s first + * hostname label is expected to end in {@code -}, which gets replaced. Otherwise, if it + * has no '.', it's a bare peer hostname; expand it using {@code ownNodeName}'s domain suffix and + * {@code :port_context}. Otherwise, returned unchanged. + */ + private static String resolvePeerShorthand(String targetNode, String ownNodeName) { + if (targetNode.contains(".")) { + return targetNode; + } + int colonIdx = ownNodeName.indexOf(':'); + String ownHost = ownNodeName.substring(0, colonIdx); + String portAndContext = ownNodeName.substring(colonIdx); + int dotIdx = ownHost.indexOf('.'); + String domainSuffix = dotIdx >= 0 ? ownHost.substring(dotIdx) : ""; + + if (targetNode.matches("\\d+")) { + String firstLabel = dotIdx >= 0 ? ownHost.substring(0, dotIdx) : ownHost; + targetNode = firstLabel.replaceFirst("-\\d+$", "-" + targetNode); + } + + return targetNode + domainSuffix + portAndContext; + } +} diff --git a/solr/webapp/web/WEB-INF/web.xml b/solr/webapp/web/WEB-INF/web.xml index 136cb6d4bd40..d878be3fdd77 100644 --- a/solr/webapp/web/WEB-INF/web.xml +++ b/solr/webapp/web/WEB-INF/web.xml @@ -61,6 +61,15 @@ SolrServlet + + NodeTargetProxyFilter + org.apache.solr.servlet.NodeTargetProxyFilter + + + NodeTargetProxyFilter + SolrServlet + + SolrServlet org.apache.solr.servlet.SolrServlet From 4616e9294aec976bec39d558247d0ed15e6228dd Mon Sep 17 00:00:00 2001 From: David Smiley Date: Tue, 20 Oct 2026 21:38:44 +0100 Subject: [PATCH 02/11] test + ref guide. code: use Solr's URL param parsing --- .../solr/servlet/NodeTargetProxyFilter.java | 12 +- .../servlet/NodeTargetProxyFilterTest.java | 130 ++++++++++++++++++ .../pages/solrcloud-distributed-requests.adoc | 23 ++++ .../apache/solr/embedded/JettySolrRunner.java | 4 +- 4 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java diff --git a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java index 65bc9d725e1d..cfef8b3a6598 100644 --- a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java +++ b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java @@ -52,14 +52,14 @@ public class NodeTargetProxyFilter extends CoreContainerAwareHttpFilter { protected void doFilter(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws IOException, ServletException { // also accept it as a query param on GET requests, for convenience when a header can't - // easily be set (e.g. a browser address bar); skipped for other methods since - // getParameter() would consume a form-urlencoded POST body + // easily be set (e.g. a browser address bar) String targetNode = req.getHeader(TARGET_NODE_HEADER); + String queryString = req.getQueryString(); if (targetNode == null && "GET".equals(req.getMethod()) - && req.getQueryString() != null - && req.getQueryString().contains(TARGET_NODE_HEADER)) { - targetNode = req.getParameter(TARGET_NODE_HEADER); // will parse + && queryString != null + && queryString.contains(TARGET_NODE_HEADER)) { + targetNode = SolrRequestParsers.parseQueryString(queryString).get(TARGET_NODE_HEADER); } if (targetNode == null || targetNode.isBlank() || !proxyToNode(targetNode, req, resp)) { chain.doFilter(req, resp); @@ -118,7 +118,7 @@ private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServl * has no '.', it's a bare peer hostname; expand it using {@code ownNodeName}'s domain suffix and * {@code :port_context}. Otherwise, returned unchanged. */ - private static String resolvePeerShorthand(String targetNode, String ownNodeName) { + static String resolvePeerShorthand(String targetNode, String ownNodeName) { if (targetNode.contains(".")) { return targetNode; } diff --git a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java new file mode 100644 index 000000000000..c257cdb95d29 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java @@ -0,0 +1,130 @@ +/* + * 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.servlet; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.solr.servlet.NodeTargetProxyFilter.TARGET_NODE_HEADER; + +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.util.Map; +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.util.Utils; +import org.apache.solr.embedded.JettySolrRunner; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Tests {@link NodeTargetProxyFilter}. */ +@SolrTestCaseJ4.SuppressSSL +public class NodeTargetProxyFilterTest extends SolrCloudTestCase { + + private static JettySolrRunner receivingNode; + private static JettySolrRunner targetNode; + + @BeforeClass + public static void setupCluster() throws Exception { + configureCluster(2).addConfig("conf", configset("cloud-minimal")).configure(); + receivingNode = cluster.getJettySolrRunner(0); + targetNode = cluster.getJettySolrRunner(1); + } + + @Test + public void testHeaderProxiesToTargetNode() throws Exception { + HttpURLConnection conn = open(receivingNode.getBaseUrl() + "/admin/info/system?wt=json"); + conn.setRequestProperty(TARGET_NODE_HEADER, targetNode.getNodeName()); + assertEquals(targetNode.getNodeName(), respondingNode(conn)); + } + + @Test + public void testHeaderProxiesV2() throws Exception { + HttpURLConnection conn = open(receivingNode.getBaseURLV2() + "/node/system"); + conn.setRequestProperty(TARGET_NODE_HEADER, targetNode.getNodeName()); + assertEquals(targetNode.getNodeName(), respondingNode(conn)); + } + + @Test + public void testQueryParamProxiesGet() throws Exception { + HttpURLConnection conn = + open( + receivingNode.getBaseUrl() + + "/admin/info/system?wt=json&" + + TARGET_NODE_HEADER + + "=" + + URLEncoder.encode(targetNode.getNodeName(), UTF_8)); + assertEquals(targetNode.getNodeName(), respondingNode(conn)); + } + + @Test + public void testQueryParamIgnoredForPost() throws Exception { + HttpURLConnection conn = + open( + receivingNode.getBaseUrl() + + "/admin/info/system?wt=json&" + + TARGET_NODE_HEADER + + "=" + + URLEncoder.encode(targetNode.getNodeName(), UTF_8)); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setFixedLengthStreamingMode(0); + conn.getOutputStream().close(); + assertEquals(receivingNode.getNodeName(), respondingNode(conn)); + } + + @Test + public void testTargetingSelfIsHandledLocally() throws Exception { + HttpURLConnection conn = open(receivingNode.getBaseUrl() + "/admin/info/system?wt=json"); + conn.setRequestProperty(TARGET_NODE_HEADER, receivingNode.getNodeName()); + assertEquals(receivingNode.getNodeName(), respondingNode(conn)); + } + + @Test + public void testUnknownNodeIs404() throws Exception { + HttpURLConnection conn = open(receivingNode.getBaseUrl() + "/admin/info/system?wt=json"); + conn.setRequestProperty(TARGET_NODE_HEADER, "no.such.host:1234_solr"); + assertEquals(404, conn.getResponseCode()); + conn.disconnect(); + } + + @Test + public void testResolvePeerShorthand() { + String own = "myhost-3.mydomain.com:3000_solr"; + assertEquals( + "otherhost.mydomain.com:3000_solr", + NodeTargetProxyFilter.resolvePeerShorthand("otherhost", own)); + assertEquals( + "otherhost.elsewhere.com:8983_solr", + NodeTargetProxyFilter.resolvePeerShorthand("otherhost.elsewhere.com:8983_solr", own)); + assertEquals( + "otherhost:3000_solr", + NodeTargetProxyFilter.resolvePeerShorthand("otherhost", "myhost:3000_solr")); + } + + private static HttpURLConnection open(String url) throws Exception { + return (HttpURLConnection) URI.create(url).toURL().openConnection(); + } + + /** Returns the "node" field from a system-info response. */ + private static String respondingNode(HttpURLConnection conn) throws Exception { + assertEquals(200, conn.getResponseCode()); + String body = new String(conn.getInputStream().readAllBytes(), UTF_8); + conn.disconnect(); + return (String) ((Map) Utils.fromJSONString(body)).get("node"); + } +} diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/solrcloud-distributed-requests.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/solrcloud-distributed-requests.adoc index 51733c2bb5bf..8c9f71b39a77 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/solrcloud-distributed-requests.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/solrcloud-distributed-requests.adoc @@ -298,6 +298,29 @@ http://localhost:8983/solr/collection1/select?q=*:*&_route_=user1! http://localhost:8983/solr/collection1/select?q=*:*&_route_=user1!,user2! ---- +== Routing a Request to a Specific Node + +Any request, including node-level APIs such as `/solr/admin/info/system` or `/api/node/system`, can be sent to a specific node by setting the `X-Solr-targetNode` HTTP header. +The node that receives the request proxies it to the named node, which processes it and returns the response. +This is useful when Solr is reached through a load balancer, where you can't otherwise choose which node handles a request. + +The value is a node name as it appears in `live_nodes` (for example, `solr2.example.com:8983_solr`). +A value without a `.` is treated as a hostname in the same domain and on the same port as the receiving node; for example, `solr2` sent to `solr1.example.com:8983_solr` targets `solr2.example.com:8983_solr`. +If the named node isn't live, the request fails with a 404 error. +If it names the receiving node, the request is processed there without proxying. + +[source,bash] +---- +curl -H "X-Solr-targetNode: solr2.example.com:8983_solr" "http://lb.example.com:8983/solr/admin/info/system" +---- + +For `GET` requests, the target node can alternatively be given as a query parameter of the same name, which is convenient in a browser: + +[source,plain] +---- +http://lb.example.com:8983/solr/admin/info/system?X-Solr-targetNode=solr2.example.com:8983_solr +---- + == Near Real Time (NRT) Use Cases Near Real Time (NRT) search means that documents are available for search soon after being indexed. diff --git a/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java b/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java index ff8253853ff1..fa06692b00dd 100644 --- a/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java +++ b/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java @@ -57,6 +57,7 @@ import org.apache.solr.servlet.AuthenticationFilter; import org.apache.solr.servlet.CoreContainerProvider; import org.apache.solr.servlet.LoadAdminUiServlet; +import org.apache.solr.servlet.NodeTargetProxyFilter; import org.apache.solr.servlet.RateLimitFilter; import org.apache.solr.servlet.RequiredSolrRequestFilter; import org.apache.solr.servlet.SolrServlet; @@ -375,7 +376,8 @@ public void contextInitialized(ServletContextEvent event) { RequiredSolrRequestFilter.class, RateLimitFilter.class, TracingFilter.class, - AuthenticationFilter.class)) { + AuthenticationFilter.class, + NodeTargetProxyFilter.class)) { FilterHolder fh = root.getServletHandler().newFilterHolder(Source.EMBEDDED); fh.setName(filterClass.getSimpleName()); fh.setHeldClass(filterClass); From d1882d9e3e74bc9bb9356f6e2a4306473c6f651f Mon Sep 17 00:00:00 2001 From: David Smiley Date: Wed, 21 Oct 2026 10:39:29 +0100 Subject: [PATCH 03/11] NodeTargetProxyFilter: review tweaks Co-Authored-By: Claude Sonnet 5.5 --- .../java/org/apache/solr/servlet/NodeTargetProxyFilter.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java index cfef8b3a6598..40b1563f55f8 100644 --- a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java +++ b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java @@ -75,15 +75,17 @@ private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServl throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Core Container Unavailable"); } if (!cores.isZooKeeperAware()) { + // note: we could adapt this filter to support standalone mode with AllowListUrlChecker throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, TARGET_NODE_HEADER + " header requires SolrCloud"); + SolrException.ErrorCode.BAD_REQUEST, + TARGET_NODE_HEADER + " header/param requires SolrCloud"); } String ownNodeName = cores.getZkController().getNodeName(); String resolvedNode = resolvePeerShorthand(targetNode, ownNodeName); ZkStateReader zkStateReader = cores.getZkController().getZkStateReader(); if (!zkStateReader.getClusterState().getLiveNodes().contains(resolvedNode)) { throw new SolrException( - SolrException.ErrorCode.NOT_FOUND, "Unknown or non-live node: " + resolvedNode); + SolrException.ErrorCode.BAD_REQUEST, "Unknown or non-live node: " + resolvedNode); } if (resolvedNode.equals(ownNodeName)) { return false; // already on the target node; let it process normally From 176ffcbe35f82eda29c29903a398fe6ebf4c8867 Mon Sep 17 00:00:00 2001 From: David Smiley Date: Wed, 21 Oct 2026 10:41:59 +0100 Subject: [PATCH 04/11] NodeTargetProxyFilter: preserve full node names with dotless hosts Co-Authored-By: Claude Sonnet 5.5 --- .../org/apache/solr/servlet/NodeTargetProxyFilter.java | 8 ++++---- .../apache/solr/servlet/NodeTargetProxyFilterTest.java | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java index 40b1563f55f8..e28137780697 100644 --- a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java +++ b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java @@ -117,12 +117,12 @@ private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServl /** * If {@code targetNode} is all digits, it's an ordinal shorthand: {@code ownNodeName}'s first * hostname label is expected to end in {@code -}, which gets replaced. Otherwise, if it - * has no '.', it's a bare peer hostname; expand it using {@code ownNodeName}'s domain suffix and - * {@code :port_context}. Otherwise, returned unchanged. + * has no '.' or ':', it's a bare peer hostname; expand it using {@code ownNodeName}'s domain + * suffix and {@code :port_context}. Otherwise, returned unchanged. */ static String resolvePeerShorthand(String targetNode, String ownNodeName) { - if (targetNode.contains(".")) { - return targetNode; + if (targetNode.contains(".") || targetNode.contains(":")) { + return targetNode; // already a full node name } int colonIdx = ownNodeName.indexOf(':'); String ownHost = ownNodeName.substring(0, colonIdx); diff --git a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java index c257cdb95d29..ea20939659fb 100644 --- a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java +++ b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java @@ -95,10 +95,10 @@ public void testTargetingSelfIsHandledLocally() throws Exception { } @Test - public void testUnknownNodeIs404() throws Exception { + public void testUnknownNodeIs400() throws Exception { HttpURLConnection conn = open(receivingNode.getBaseUrl() + "/admin/info/system?wt=json"); conn.setRequestProperty(TARGET_NODE_HEADER, "no.such.host:1234_solr"); - assertEquals(404, conn.getResponseCode()); + assertEquals(400, conn.getResponseCode()); conn.disconnect(); } @@ -114,6 +114,10 @@ public void testResolvePeerShorthand() { assertEquals( "otherhost:3000_solr", NodeTargetProxyFilter.resolvePeerShorthand("otherhost", "myhost:3000_solr")); + // complete node names with dotless hosts are preserved + assertEquals( + "solr2:8983_solr", + NodeTargetProxyFilter.resolvePeerShorthand("solr2:8983_solr", "solr1:8983_solr")); } private static HttpURLConnection open(String url) throws Exception { From 7c66491a0f5c733c0f6eb42bfc9529b88117422f Mon Sep 17 00:00:00 2001 From: David Smiley Date: Wed, 21 Oct 2026 20:24:21 +0000 Subject: [PATCH 05/11] rename var to match super --- .../org/apache/solr/servlet/NodeTargetProxyFilter.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java index e28137780697..f91f83bef61c 100644 --- a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java +++ b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java @@ -49,7 +49,7 @@ public class NodeTargetProxyFilter extends CoreContainerAwareHttpFilter { public static final String TARGET_NODE_HEADER = "X-Solr-targetNode"; @Override - protected void doFilter(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) + protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { // also accept it as a query param on GET requests, for convenience when a header can't // easily be set (e.g. a browser address bar) @@ -61,13 +61,13 @@ protected void doFilter(HttpServletRequest req, HttpServletResponse resp, Filter && queryString.contains(TARGET_NODE_HEADER)) { targetNode = SolrRequestParsers.parseQueryString(queryString).get(TARGET_NODE_HEADER); } - if (targetNode == null || targetNode.isBlank() || !proxyToNode(targetNode, req, resp)) { - chain.doFilter(req, resp); + if (targetNode == null || targetNode.isBlank() || !proxyToNode(targetNode, req, res)) { + chain.doFilter(req, res); } } /** Returns true if the request was proxied to {@code targetNode}. */ - private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServletResponse resp) { + private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServletResponse res) { CoreContainer cores; try { cores = getCores(); @@ -100,7 +100,7 @@ private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServl try { HttpClient httpClient = ((HttpJettySolrClient) cores.getDefaultHttpSolrClient()).getHttpClient(); - HttpSolrProxy.doHttpProxy(httpClient, req, resp, url); + HttpSolrProxy.doHttpProxy(httpClient, req, res, url); } catch (Exception e) { throw new SolrException( SolrException.ErrorCode.SERVER_ERROR, From 1d7fc1a7a40d880c9661fde596b01b9021bbedac Mon Sep 17 00:00:00 2001 From: David Smiley Date: Wed, 21 Oct 2026 21:39:18 -0400 Subject: [PATCH 06/11] NodeTargetProxyFilter: consume the targeting header/param to avoid routing loops Co-Authored-By: Claude Sonnet 5.5 --- .../solr/servlet/NodeTargetProxyFilter.java | 68 ++++++++++++++-- .../servlet/NodeTargetProxyFilterTest.java | 79 +++++++++++++++++++ 2 files changed, 142 insertions(+), 5 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java index f91f83bef61c..eb7eee10881e 100644 --- a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java +++ b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java @@ -21,11 +21,18 @@ import jakarta.servlet.ServletException; import jakarta.servlet.UnavailableException; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; import org.apache.solr.common.SolrException; import org.apache.solr.common.cloud.ZkStateReader; +import org.apache.solr.common.params.ModifiableSolrParams; +import org.apache.solr.common.params.SolrParams; import org.apache.solr.core.CoreContainer; import org.eclipse.jetty.client.HttpClient; @@ -51,21 +58,72 @@ public class NodeTargetProxyFilter extends CoreContainerAwareHttpFilter { @Override protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { + + String targetNode = req.getHeader(TARGET_NODE_HEADER); + // also accept it as a query param on GET requests, for convenience when a header can't // easily be set (e.g. a browser address bar) - String targetNode = req.getHeader(TARGET_NODE_HEADER); String queryString = req.getQueryString(); - if (targetNode == null - && "GET".equals(req.getMethod()) + if ("GET".equals(req.getMethod()) && queryString != null - && queryString.contains(TARGET_NODE_HEADER)) { - targetNode = SolrRequestParsers.parseQueryString(queryString).get(TARGET_NODE_HEADER); + && queryString.contains(TARGET_NODE_HEADER)) { // cheap; avoid double-parse + SolrParams params = SolrRequestParsers.parseQueryString(queryString); + if (targetNode != null && params.get(TARGET_NODE_HEADER) != null) { + res.sendError(400, TARGET_NODE_HEADER + " must EITHER be a header or url param"); + return; + } + targetNode = params.get(TARGET_NODE_HEADER); + if (targetNode != null) { + queryString = filterQueryString(params); + } + } + + // wrap the request to filter out the header & param. + // NOTE: we assume downstream processing only accesses such data via what we override !! + if (targetNode != null) { + final List headerNames = filterHeaderNamesToList(req); // cheap + final String finalQueryString = queryString; // filtered already if needed + req = + new HttpServletRequestWrapper(req) { + @Override + public Enumeration getHeaderNames() { + return Collections.enumeration(headerNames); + } + + @Override + public String getQueryString() { + return finalQueryString; + } + }; } + if (targetNode == null || targetNode.isBlank() || !proxyToNode(targetNode, req, res)) { chain.doFilter(req, res); } } + private static List filterHeaderNamesToList(HttpServletRequest req) { + List headerNames = new ArrayList<>(); + var headerNamesEnum = req.getHeaderNames(); + while (headerNamesEnum.hasMoreElements()) { + String headerName = headerNamesEnum.nextElement(); + if (!headerName.equalsIgnoreCase(TARGET_NODE_HEADER)) { + headerNames.add(headerName); + } + } + return headerNames; + } + + private static String filterQueryString(SolrParams params) { + ModifiableSolrParams modifiableSolrParams = ModifiableSolrParams.of(params); + modifiableSolrParams.remove(TARGET_NODE_HEADER); + String queryString = modifiableSolrParams.toQueryString(); + if (queryString.startsWith("?")) { + queryString = queryString.substring(1); + } + return queryString; // could be empty string + } + /** Returns true if the request was proxied to {@code targetNode}. */ private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServletResponse res) { CoreContainer cores; diff --git a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java index ea20939659fb..5b16152608d3 100644 --- a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java +++ b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java @@ -24,10 +24,18 @@ import java.net.URI; import java.net.URLEncoder; import java.util.Map; +import java.util.TreeMap; import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.cloud.ConfigRequest; import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.common.util.Utils; import org.apache.solr.embedded.JettySolrRunner; +import org.apache.solr.handler.RequestHandlerBase; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; +import org.apache.solr.security.AuthorizationContext; import org.junit.BeforeClass; import org.junit.Test; @@ -38,11 +46,51 @@ public class NodeTargetProxyFilterTest extends SolrCloudTestCase { private static JettySolrRunner receivingNode; private static JettySolrRunner targetNode; + /** A collection with its only replica on {@link #receivingNode}. */ + private static final String COLLECTION = "coll"; + @BeforeClass public static void setupCluster() throws Exception { configureCluster(2).addConfig("conf", configset("cloud-minimal")).configure(); receivingNode = cluster.getJettySolrRunner(0); targetNode = cluster.getJettySolrRunner(1); + + CollectionAdminRequest.createCollection(COLLECTION, "conf", 1, 1) + .setCreateNodeSet(receivingNode.getNodeName()) + .process(cluster.getSolrClient()); + cluster + .getSolrClient() + .request( + new ConfigRequest( + "{'add-requesthandler': {'name': '/echo', 'class': '" + + EchoHandler.class.getName() + + "'}}"), + COLLECTION); + } + + /** Echoes the headers and params of the HTTP request as seen by the handler. */ + public static class EchoHandler extends RequestHandlerBase { + @Override + public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) { + Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + var httpReq = req.getHttpSolrCall().getReq(); + httpReq + .getHeaderNames() + .asIterator() + .forEachRemaining(name -> headers.put(name, httpReq.getHeader(name))); + rsp.add("headers", headers); + rsp.add("params", new SimpleOrderedMap<>(req.getParams())); + } + + @Override + public String getDescription() { + return "echoes request headers and params"; + } + + @Override + public Name getPermissionName(AuthorizationContext ctx) { + return Name.ALL; + } } @Test @@ -87,6 +135,37 @@ public void testQueryParamIgnoredForPost() throws Exception { assertEquals(receivingNode.getNodeName(), respondingNode(conn)); } + /** + * The only replica is on the receiving node, so the target node forwards the request back to it. + * The targeting directive must be consumed, or the request would bounce back and forth. + */ + @SuppressWarnings("unchecked") + @Test + public void testDirectiveConsumedWhenTargetForwardsToReplica() throws Exception { + boolean useHeader = random().nextBoolean(); + String url = receivingNode.getBaseUrl() + "/" + COLLECTION + "/echo?wt=json&foo=bar"; + if (!useHeader) { + url += "&" + TARGET_NODE_HEADER + "=" + URLEncoder.encode(targetNode.getNodeName(), UTF_8); + } + HttpURLConnection conn = open(url); + conn.setRequestProperty("X-Other", "kept"); + if (useHeader) { + conn.setRequestProperty(TARGET_NODE_HEADER, targetNode.getNodeName()); + } + assertEquals(200, conn.getResponseCode()); + Map rsp = + (Map) Utils.fromJSONString(new String(conn.getInputStream().readAllBytes(), UTF_8)); + conn.disconnect(); + + Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + headers.putAll((Map) rsp.get("headers")); + assertEquals("kept", headers.get("X-Other")); + assertFalse(headers.containsKey(TARGET_NODE_HEADER)); + Map params = (Map) rsp.get("params"); + assertEquals("bar", params.get("foo")); + assertFalse(params.containsKey(TARGET_NODE_HEADER)); + } + @Test public void testTargetingSelfIsHandledLocally() throws Exception { HttpURLConnection conn = open(receivingNode.getBaseUrl() + "/admin/info/system?wt=json"); From b5cb63b54b774e8292ca9631541d13c53cc87776 Mon Sep 17 00:00:00 2001 From: David Smiley Date: Wed, 21 Oct 2026 21:51:22 -0400 Subject: [PATCH 07/11] NodeTargetProxyFilter: proxy using the still-encoded request path Co-Authored-By: Claude Sonnet 5.5 --- .../solr/servlet/NodeTargetProxyFilter.java | 8 +++++-- .../servlet/NodeTargetProxyFilterTest.java | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java index eb7eee10881e..95d80c47c863 100644 --- a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java +++ b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java @@ -149,8 +149,7 @@ private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServl return false; // already on the target node; let it process normally } - String url = - zkStateReader.getBaseUrlForNodeName(resolvedNode) + ServletUtils.getPathAfterContext(req); + String url = zkStateReader.getBaseUrlForNodeName(resolvedNode) + pathAfterContext(req); String queryString = req.getQueryString(); if (queryString != null) { url = url + "?" + queryString; @@ -172,6 +171,11 @@ private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServl return true; } + /** The still-encoded request path with the context path removed. Starts with "/". */ + private static String pathAfterContext(HttpServletRequest req) { + return req.getRequestURI().substring(req.getContextPath().length()); + } + /** * If {@code targetNode} is all digits, it's an ordinal shorthand: {@code ownNodeName}'s first * hostname label is expected to end in {@code -}, which gets replaced. Otherwise, if it diff --git a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java index 5b16152608d3..83c1c706ec4d 100644 --- a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java +++ b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java @@ -34,6 +34,7 @@ import org.apache.solr.embedded.JettySolrRunner; import org.apache.solr.handler.RequestHandlerBase; import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.request.SolrRequestHandler; import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.security.AuthorizationContext; import org.junit.BeforeClass; @@ -79,9 +80,15 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) { .asIterator() .forEachRemaining(name -> headers.put(name, httpReq.getHeader(name))); rsp.add("headers", headers); + rsp.add("requestURI", httpReq.getRequestURI()); rsp.add("params", new SimpleOrderedMap<>(req.getParams())); } + @Override + public SolrRequestHandler getSubHandler(String subPath) { + return this; + } + @Override public String getDescription() { return "echoes request headers and params"; @@ -166,6 +173,21 @@ public void testDirectiveConsumedWhenTargetForwardsToReplica() throws Exception assertFalse(params.containsKey(TARGET_NODE_HEADER)); } + /** The path is proxied as received, still encoded. */ + @Test + public void testEncodedPathPreserved() throws Exception { + // the replica is on receivingNode, so the proxied request is served directly (no further hop) + String encodedPath = "/a%20b%23c"; + HttpURLConnection conn = + open(targetNode.getBaseUrl() + "/" + COLLECTION + "/echo" + encodedPath + "?wt=json"); + conn.setRequestProperty(TARGET_NODE_HEADER, receivingNode.getNodeName()); + assertEquals(200, conn.getResponseCode()); + Map rsp = + (Map) Utils.fromJSONString(new String(conn.getInputStream().readAllBytes(), UTF_8)); + conn.disconnect(); + assertEquals("/solr/" + COLLECTION + "/echo" + encodedPath, rsp.get("requestURI")); + } + @Test public void testTargetingSelfIsHandledLocally() throws Exception { HttpURLConnection conn = open(receivingNode.getBaseUrl() + "/admin/info/system?wt=json"); From b4dba651e26195988c728c0e6797133955d4617f Mon Sep 17 00:00:00 2001 From: David Smiley Date: Wed, 21 Oct 2026 22:08:17 -0400 Subject: [PATCH 08/11] NodeTargetProxyFilterTest: use SolrJ so it works with SSL Co-Authored-By: Claude Sonnet 5.5 --- .../servlet/NodeTargetProxyFilterTest.java | 123 ++++++++---------- 1 file changed, 55 insertions(+), 68 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java index 83c1c706ec4d..749eb1134598 100644 --- a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java +++ b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java @@ -17,20 +17,21 @@ package org.apache.solr.servlet; -import static java.nio.charset.StandardCharsets.UTF_8; import static org.apache.solr.servlet.NodeTargetProxyFilter.TARGET_NODE_HEADER; -import java.net.HttpURLConnection; -import java.net.URI; -import java.net.URLEncoder; import java.util.Map; import java.util.TreeMap; -import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.client.solrj.request.GenericSolrRequest; +import org.apache.solr.client.solrj.request.V2Request; import org.apache.solr.cloud.ConfigRequest; import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.ModifiableSolrParams; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SimpleOrderedMap; -import org.apache.solr.common.util.Utils; import org.apache.solr.embedded.JettySolrRunner; import org.apache.solr.handler.RequestHandlerBase; import org.apache.solr.request.SolrQueryRequest; @@ -41,7 +42,6 @@ import org.junit.Test; /** Tests {@link NodeTargetProxyFilter}. */ -@SolrTestCaseJ4.SuppressSSL public class NodeTargetProxyFilterTest extends SolrCloudTestCase { private static JettySolrRunner receivingNode; @@ -102,44 +102,34 @@ public Name getPermissionName(AuthorizationContext ctx) { @Test public void testHeaderProxiesToTargetNode() throws Exception { - HttpURLConnection conn = open(receivingNode.getBaseUrl() + "/admin/info/system?wt=json"); - conn.setRequestProperty(TARGET_NODE_HEADER, targetNode.getNodeName()); - assertEquals(targetNode.getNodeName(), respondingNode(conn)); + var req = systemInfoRequest(SolrRequest.METHOD.GET, params()); + req.addHeader(TARGET_NODE_HEADER, targetNode.getNodeName()); + assertEquals(targetNode.getNodeName(), respondingNode(receivingNode, req)); } @Test public void testHeaderProxiesV2() throws Exception { - HttpURLConnection conn = open(receivingNode.getBaseURLV2() + "/node/system"); - conn.setRequestProperty(TARGET_NODE_HEADER, targetNode.getNodeName()); - assertEquals(targetNode.getNodeName(), respondingNode(conn)); + var req = new V2Request.Builder("/node/system").GET().build(); + req.addHeader(TARGET_NODE_HEADER, targetNode.getNodeName()); + assertEquals(targetNode.getNodeName(), respondingNode(receivingNode, req)); } @Test public void testQueryParamProxiesGet() throws Exception { - HttpURLConnection conn = - open( - receivingNode.getBaseUrl() - + "/admin/info/system?wt=json&" - + TARGET_NODE_HEADER - + "=" - + URLEncoder.encode(targetNode.getNodeName(), UTF_8)); - assertEquals(targetNode.getNodeName(), respondingNode(conn)); + var req = + systemInfoRequest( + SolrRequest.METHOD.GET, params().set(TARGET_NODE_HEADER, targetNode.getNodeName())); + assertEquals(targetNode.getNodeName(), respondingNode(receivingNode, req)); } @Test public void testQueryParamIgnoredForPost() throws Exception { - HttpURLConnection conn = - open( - receivingNode.getBaseUrl() - + "/admin/info/system?wt=json&" - + TARGET_NODE_HEADER - + "=" - + URLEncoder.encode(targetNode.getNodeName(), UTF_8)); - conn.setRequestMethod("POST"); - conn.setDoOutput(true); - conn.setFixedLengthStreamingMode(0); - conn.getOutputStream().close(); - assertEquals(receivingNode.getNodeName(), respondingNode(conn)); + // a body forces the params onto the URL's query string + var req = + systemInfoRequest( + SolrRequest.METHOD.POST, params().set(TARGET_NODE_HEADER, targetNode.getNodeName())) + .withContent(new byte[0], "text/plain"); + assertEquals(receivingNode.getNodeName(), respondingNode(receivingNode, req)); } /** @@ -149,28 +139,25 @@ public void testQueryParamIgnoredForPost() throws Exception { @SuppressWarnings("unchecked") @Test public void testDirectiveConsumedWhenTargetForwardsToReplica() throws Exception { + var params = params(); boolean useHeader = random().nextBoolean(); - String url = receivingNode.getBaseUrl() + "/" + COLLECTION + "/echo?wt=json&foo=bar"; if (!useHeader) { - url += "&" + TARGET_NODE_HEADER + "=" + URLEncoder.encode(targetNode.getNodeName(), UTF_8); + params.set(TARGET_NODE_HEADER, targetNode.getNodeName()); } - HttpURLConnection conn = open(url); - conn.setRequestProperty("X-Other", "kept"); + var req = new GenericSolrRequest(SolrRequest.METHOD.GET, "/" + COLLECTION + "/echo", params); + req.addHeader("X-Other", "kept"); if (useHeader) { - conn.setRequestProperty(TARGET_NODE_HEADER, targetNode.getNodeName()); + req.addHeader(TARGET_NODE_HEADER, targetNode.getNodeName()); } - assertEquals(200, conn.getResponseCode()); - Map rsp = - (Map) Utils.fromJSONString(new String(conn.getInputStream().readAllBytes(), UTF_8)); - conn.disconnect(); + NamedList rsp = receivingNode.getSolrClient().request(req); Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); headers.putAll((Map) rsp.get("headers")); assertEquals("kept", headers.get("X-Other")); assertFalse(headers.containsKey(TARGET_NODE_HEADER)); - Map params = (Map) rsp.get("params"); - assertEquals("bar", params.get("foo")); - assertFalse(params.containsKey(TARGET_NODE_HEADER)); + NamedList echoedParams = (NamedList) rsp.get("params"); + assertEquals("bar", echoedParams.get("foo")); + assertNull(echoedParams.get(TARGET_NODE_HEADER)); } /** The path is proxied as received, still encoded. */ @@ -178,29 +165,27 @@ public void testDirectiveConsumedWhenTargetForwardsToReplica() throws Exception public void testEncodedPathPreserved() throws Exception { // the replica is on receivingNode, so the proxied request is served directly (no further hop) String encodedPath = "/a%20b%23c"; - HttpURLConnection conn = - open(targetNode.getBaseUrl() + "/" + COLLECTION + "/echo" + encodedPath + "?wt=json"); - conn.setRequestProperty(TARGET_NODE_HEADER, receivingNode.getNodeName()); - assertEquals(200, conn.getResponseCode()); - Map rsp = - (Map) Utils.fromJSONString(new String(conn.getInputStream().readAllBytes(), UTF_8)); - conn.disconnect(); + var req = + new GenericSolrRequest( + SolrRequest.METHOD.GET, "/" + COLLECTION + "/echo" + encodedPath, params()); + req.addHeader(TARGET_NODE_HEADER, receivingNode.getNodeName()); + NamedList rsp = targetNode.getSolrClient().request(req); assertEquals("/solr/" + COLLECTION + "/echo" + encodedPath, rsp.get("requestURI")); } @Test public void testTargetingSelfIsHandledLocally() throws Exception { - HttpURLConnection conn = open(receivingNode.getBaseUrl() + "/admin/info/system?wt=json"); - conn.setRequestProperty(TARGET_NODE_HEADER, receivingNode.getNodeName()); - assertEquals(receivingNode.getNodeName(), respondingNode(conn)); + var req = systemInfoRequest(SolrRequest.METHOD.GET, params()); + req.addHeader(TARGET_NODE_HEADER, receivingNode.getNodeName()); + assertEquals(receivingNode.getNodeName(), respondingNode(receivingNode, req)); } @Test - public void testUnknownNodeIs400() throws Exception { - HttpURLConnection conn = open(receivingNode.getBaseUrl() + "/admin/info/system?wt=json"); - conn.setRequestProperty(TARGET_NODE_HEADER, "no.such.host:1234_solr"); - assertEquals(400, conn.getResponseCode()); - conn.disconnect(); + public void testUnknownNodeIs400() { + var req = systemInfoRequest(SolrRequest.METHOD.GET, params()); + req.addHeader(TARGET_NODE_HEADER, "no.such.host:1234_solr"); + var e = expectThrows(SolrException.class, () -> receivingNode.getSolrClient().request(req)); + assertEquals(400, e.code()); } @Test @@ -221,15 +206,17 @@ public void testResolvePeerShorthand() { NodeTargetProxyFilter.resolvePeerShorthand("solr2:8983_solr", "solr1:8983_solr")); } - private static HttpURLConnection open(String url) throws Exception { - return (HttpURLConnection) URI.create(url).toURL().openConnection(); + private static GenericSolrRequest systemInfoRequest( + SolrRequest.METHOD method, SolrParams params) { + return new GenericSolrRequest( + method, "/admin/info/system", SolrRequest.SolrRequestType.ADMIN, params); } - /** Returns the "node" field from a system-info response. */ - private static String respondingNode(HttpURLConnection conn) throws Exception { - assertEquals(200, conn.getResponseCode()); - String body = new String(conn.getInputStream().readAllBytes(), UTF_8); - conn.disconnect(); - return (String) ((Map) Utils.fromJSONString(body)).get("node"); + private static ModifiableSolrParams params() { + return new ModifiableSolrParams().set("foo", "bar"); // including an arbitrary pair + } + + private static String respondingNode(JettySolrRunner node, SolrRequest req) throws Exception { + return (String) node.getSolrClient().request(req).get("node"); } } From de525ca7291e85f1260956369254b21f79634ccf Mon Sep 17 00:00:00 2001 From: David Smiley Date: Wed, 21 Oct 2026 22:26:56 -0400 Subject: [PATCH 09/11] NodeTargetProxyFilter: report errors directly so they survive AuthenticationFilter Co-Authored-By: Claude Sonnet 5.5 --- .../solr/servlet/NodeTargetProxyFilter.java | 42 ++++++--- .../NodeTargetProxyFilterAuthTest.java | 93 +++++++++++++++++++ 2 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterAuthTest.java diff --git a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java index 95d80c47c863..d31a1e0f57e8 100644 --- a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java +++ b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java @@ -17,6 +17,10 @@ package org.apache.solr.servlet; +import static org.apache.solr.common.SolrException.ErrorCode.BAD_REQUEST; +import static org.apache.solr.common.SolrException.ErrorCode.SERVER_ERROR; +import static org.apache.solr.common.SolrException.ErrorCode.SERVICE_UNAVAILABLE; + import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.UnavailableException; @@ -24,17 +28,19 @@ import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.lang.invoke.MethodHandles; import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; import java.util.List; import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; -import org.apache.solr.common.SolrException; import org.apache.solr.common.cloud.ZkStateReader; import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.params.SolrParams; import org.apache.solr.core.CoreContainer; import org.eclipse.jetty.client.HttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * If the request carries the {@link #TARGET_NODE_HEADER} header naming a different live node, @@ -45,6 +51,8 @@ */ public class NodeTargetProxyFilter extends CoreContainerAwareHttpFilter { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + /** * Header naming a live node (as it appears in ZK live_nodes) to proxy this request to. Two * shorthand forms are also accepted, both resolved relative to the current node: a value of all @@ -97,9 +105,10 @@ public String getQueryString() { }; } - if (targetNode == null || targetNode.isBlank() || !proxyToNode(targetNode, req, res)) { - chain.doFilter(req, res); + if (targetNode != null && !targetNode.isBlank() && proxyToNode(targetNode, req, res)) { + return; } + chain.doFilter(req, res); } private static List filterHeaderNamesToList(HttpServletRequest req) { @@ -124,26 +133,31 @@ private static String filterQueryString(SolrParams params) { return queryString; // could be empty string } - /** Returns true if the request was proxied to {@code targetNode}. */ - private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServletResponse res) { + /** + * Returns true if the request was handled here: proxied to {@code targetNode}, or answered with + * an error. Reports errors itself, since an exception would be treated by {@link + * AuthenticationFilter}, our caller, as an authentication failure. + */ + private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServletResponse res) + throws IOException { CoreContainer cores; try { cores = getCores(); } catch (UnavailableException e) { - throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Core Container Unavailable"); + res.sendError(SERVICE_UNAVAILABLE.code, "Core Container Unavailable"); + return true; } if (!cores.isZooKeeperAware()) { // note: we could adapt this filter to support standalone mode with AllowListUrlChecker - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - TARGET_NODE_HEADER + " header/param requires SolrCloud"); + res.sendError(BAD_REQUEST.code, TARGET_NODE_HEADER + " header/param requires SolrCloud"); + return true; } String ownNodeName = cores.getZkController().getNodeName(); String resolvedNode = resolvePeerShorthand(targetNode, ownNodeName); ZkStateReader zkStateReader = cores.getZkController().getZkStateReader(); if (!zkStateReader.getClusterState().getLiveNodes().contains(resolvedNode)) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, "Unknown or non-live node: " + resolvedNode); + res.sendError(BAD_REQUEST.code, "Unknown or non-live node: " + resolvedNode); + return true; } if (resolvedNode.equals(ownNodeName)) { return false; // already on the target node; let it process normally @@ -159,10 +173,8 @@ private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServl ((HttpJettySolrClient) cores.getDefaultHttpSolrClient()).getHttpClient(); HttpSolrProxy.doHttpProxy(httpClient, req, res, url); } catch (Exception e) { - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, - "Error proxying request to node: " + resolvedNode, - e); + log.warn("Error proxying request to node {}", resolvedNode, e); + res.sendError(SERVER_ERROR.code, "Error proxying request to node: " + resolvedNode); } catch (Error e) { throw e; } catch (Throwable e) { diff --git a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterAuthTest.java b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterAuthTest.java new file mode 100644 index 000000000000..0af7be09fd06 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterAuthTest.java @@ -0,0 +1,93 @@ +/* + * 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.servlet; + +import static org.apache.solr.servlet.NodeTargetProxyFilter.TARGET_NODE_HEADER; + +import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.request.GenericSolrRequest; +import org.apache.solr.cloud.SolrCloudAuthTestCase; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.embedded.JettySolrRunner; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Tests {@link NodeTargetProxyFilter} with authentication enabled. */ +public class NodeTargetProxyFilterAuthTest extends SolrCloudAuthTestCase { + + private static final String USER = "solr"; + private static final String PASSWORD = "SolrRocks"; + + private static final String SECURITY_JSON = + """ + { + "authentication": { + "blockUnknown": true, + "class": "solr.BasicAuthPlugin", + "credentials": {"solr": "JeRyxP8A3dVWhFgFbf/Eg2RXmuoU8BE5gbNQyxmGAJQ= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw="} + } + } + """; + + private static JettySolrRunner receivingNode; + private static JettySolrRunner targetNode; + + @BeforeClass + public static void setupCluster() throws Exception { + configureCluster(2) + .addConfig("conf", configset("cloud-minimal")) + .withSecurityJson(SECURITY_JSON) + .configure(); + receivingNode = cluster.getJettySolrRunner(0); + targetNode = cluster.getJettySolrRunner(1); + } + + @Test + public void testProxiesWhenAuthenticated() throws Exception { + var req = systemInfoRequest(targetNode.getNodeName()); + req.setBasicAuthCredentials(USER, PASSWORD); + assertEquals(targetNode.getNodeName(), receivingNode.getSolrClient().request(req).get("node")); + } + + @Test + public void testUnknownNodeIs400WhenAuthenticated() { + var req = systemInfoRequest("no.such.host:1234_solr"); + req.setBasicAuthCredentials(USER, PASSWORD); + var e = expectThrows(SolrException.class, () -> receivingNode.getSolrClient().request(req)); + assertEquals(400, e.code()); + } + + @Test + public void testUnauthenticatedIs401() { + var req = systemInfoRequest(targetNode.getNodeName()); + var e = expectThrows(SolrException.class, () -> receivingNode.getSolrClient().request(req)); + assertEquals(401, e.code()); + } + + private static GenericSolrRequest systemInfoRequest(String targetNodeName) { + var req = + new GenericSolrRequest( + SolrRequest.METHOD.GET, + "/admin/info/system", + SolrRequest.SolrRequestType.ADMIN, + SolrParams.of()); + req.addHeader(TARGET_NODE_HEADER, targetNodeName); + return req; + } +} From d033e8b3da1639f43a4a16f527837b85f5828e61 Mon Sep 17 00:00:00 2001 From: David Smiley Date: Wed, 21 Oct 2026 23:37:56 -0400 Subject: [PATCH 10/11] NodeTargetProxyFilterAuthTest: proxied requests are authorized as the caller Co-Authored-By: Claude Sonnet 5.5 --- .../NodeTargetProxyFilterAuthTest.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterAuthTest.java b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterAuthTest.java index 0af7be09fd06..3f3df71852ff 100644 --- a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterAuthTest.java +++ b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterAuthTest.java @@ -40,7 +40,15 @@ public class NodeTargetProxyFilterAuthTest extends SolrCloudAuthTestCase { "authentication": { "blockUnknown": true, "class": "solr.BasicAuthPlugin", - "credentials": {"solr": "JeRyxP8A3dVWhFgFbf/Eg2RXmuoU8BE5gbNQyxmGAJQ= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw="} + "credentials": { + "solr": "JeRyxP8A3dVWhFgFbf/Eg2RXmuoU8BE5gbNQyxmGAJQ= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw=", + "limited": "JeRyxP8A3dVWhFgFbf/Eg2RXmuoU8BE5gbNQyxmGAJQ= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw=" + } + }, + "authorization": { + "class": "solr.RuleBasedAuthorizationPlugin", + "user-role": {"solr": "admin"}, + "permissions": [{"name": "all", "role": "admin"}] } } """; @@ -80,6 +88,15 @@ public void testUnauthenticatedIs401() { assertEquals(401, e.code()); } + /** The target node authorizes the original caller, not the proxying node. */ + @Test + public void testProxiedRequestAuthorizedAsCaller() { + var req = systemInfoRequest(targetNode.getNodeName()); + req.setBasicAuthCredentials("limited", PASSWORD); + var e = expectThrows(SolrException.class, () -> receivingNode.getSolrClient().request(req)); + assertEquals(403, e.code()); + } + private static GenericSolrRequest systemInfoRequest(String targetNodeName) { var req = new GenericSolrRequest( From b4fa46f1ecb11a60cc2cfd4783492639bb4e3a85 Mon Sep 17 00:00:00 2001 From: David Smiley Date: Thu, 22 Oct 2026 11:56:45 -0400 Subject: [PATCH 11/11] HttpSolrProxy: take a URI; build it with an encoded path and query Co-Authored-By: Claude Sonnet 5.5 --- .../org/apache/solr/servlet/HttpSolrCall.java | 8 +++--- .../apache/solr/servlet/HttpSolrProxy.java | 6 ++--- .../solr/servlet/NodeTargetProxyFilter.java | 27 +++++++++++++++---- .../servlet/NodeTargetProxyFilterTest.java | 17 ++++++++++++ 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java b/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java index 35ab076a673d..c202ee08dbec 100644 --- a/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java +++ b/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java @@ -35,6 +35,7 @@ import java.io.EOFException; import java.io.IOException; import java.lang.invoke.MethodHandles; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; @@ -643,13 +644,14 @@ protected void sendRemoteProxy() throws IOException { updatedQueryParams.set(INTERNAL_REQUEST_COUNT, forwardCount); String queryStr = updatedQueryParams.toQueryString(); - String coreUrlAndPath = coreUrl + path; - log.info("Proxying request to: {}", coreUrlAndPath); + log.info("Proxying request to: {}{}", coreUrl, path); try { + // path is decoded, so encode it + URI uri = URI.create(coreUrl + new URI(null, null, path, null).getRawPath() + queryStr); response.reset(); // clear all headers and status HttpClient httpClient = ((HttpJettySolrClient) cores.getDefaultHttpSolrClient()).getHttpClient(); - HttpSolrProxy.doHttpProxy(httpClient, req, response, coreUrlAndPath + queryStr); + HttpSolrProxy.doHttpProxy(httpClient, req, response, uri); } catch (Exception e) { // note: don't handle interruption differently; we are stopping sendError( diff --git a/solr/core/src/java/org/apache/solr/servlet/HttpSolrProxy.java b/solr/core/src/java/org/apache/solr/servlet/HttpSolrProxy.java index 3c57062906f4..d23f5d742aef 100644 --- a/solr/core/src/java/org/apache/solr/servlet/HttpSolrProxy.java +++ b/solr/core/src/java/org/apache/solr/servlet/HttpSolrProxy.java @@ -21,6 +21,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.OutputStream; +import java.net.URI; import java.nio.ByteBuffer; import java.util.EnumSet; import java.util.Set; @@ -60,10 +61,7 @@ class HttpSolrProxy { private static final Set NO_BODY_METHODS = Set.of("GET", "HEAD", "DELETE"); static void doHttpProxy( - HttpClient httpClient, - HttpServletRequest servletReq, - HttpServletResponse servletRsp, - String url) + HttpClient httpClient, HttpServletRequest servletReq, HttpServletResponse servletRsp, URI url) throws Throwable { Request proxyReq = httpClient.newRequest(url).method(servletReq.getMethod()); diff --git a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java index d31a1e0f57e8..d49f9823b1b4 100644 --- a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java +++ b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java @@ -29,11 +29,13 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.lang.invoke.MethodHandles; +import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; import java.util.List; import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; +import org.apache.solr.common.SolrException; import org.apache.solr.common.cloud.ZkStateReader; import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.params.SolrParams; @@ -163,15 +165,20 @@ private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServl return false; // already on the target node; let it process normally } - String url = zkStateReader.getBaseUrlForNodeName(resolvedNode) + pathAfterContext(req); - String queryString = req.getQueryString(); - if (queryString != null) { - url = url + "?" + queryString; + URI uri; + try { + uri = targetUri(zkStateReader.getBaseUrlForNodeName(resolvedNode), req); + } catch (SolrException e) { + res.sendError(e.code(), e.getMessage()); + return true; + } catch (IllegalArgumentException e) { + res.sendError(BAD_REQUEST.code, "Invalid request URI"); + return true; } try { HttpClient httpClient = ((HttpJettySolrClient) cores.getDefaultHttpSolrClient()).getHttpClient(); - HttpSolrProxy.doHttpProxy(httpClient, req, res, url); + HttpSolrProxy.doHttpProxy(httpClient, req, res, uri); } catch (Exception e) { log.warn("Error proxying request to node {}", resolvedNode, e); res.sendError(SERVER_ERROR.code, "Error proxying request to node: " + resolvedNode); @@ -183,6 +190,16 @@ private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServl return true; } + /** + * The request's URI on another node. The path is reused as received (still encoded). The query is + * re-encoded because Jetty accepts characters in it, like '{' or '|', that a URI rejects. + */ + private static URI targetUri(String baseUrl, HttpServletRequest req) { + String query = req.getQueryString(); + query = query == null ? "" : SolrRequestParsers.parseQueryString(query).toQueryString(); + return URI.create(baseUrl + pathAfterContext(req) + query); + } + /** The still-encoded request path with the context path removed. Starts with "/". */ private static String pathAfterContext(HttpServletRequest req) { return req.getRequestURI().substring(req.getContextPath().length()); diff --git a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java index 749eb1134598..8b8f5d3ed862 100644 --- a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java +++ b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java @@ -32,6 +32,7 @@ import org.apache.solr.common.params.SolrParams; import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SimpleOrderedMap; +import org.apache.solr.common.util.Utils; import org.apache.solr.embedded.JettySolrRunner; import org.apache.solr.handler.RequestHandlerBase; import org.apache.solr.request.SolrQueryRequest; @@ -173,6 +174,22 @@ public void testEncodedPathPreserved() throws Exception { assertEquals("/solr/" + COLLECTION + "/echo" + encodedPath, rsp.get("requestURI")); } + /** Jetty accepts query characters that are invalid in a URI (e.g. a local-params '{'). */ + @Test + public void testUnencodedQueryCharsProxied() throws Exception { + var httpClient = targetNode.getSolrClient().getHttpClient(); + var rsp = + httpClient + .newRequest(targetNode.getBaseUrl().toString()) + .path("/solr/" + COLLECTION + "/echo?wt=json&foo={!x}|b^2") + .headers(h -> h.add(TARGET_NODE_HEADER, receivingNode.getNodeName())) + .send(); + assertEquals(200, rsp.getStatus()); + Map params = + (Map) ((Map) Utils.fromJSONString(rsp.getContentAsString())).get("params"); + assertEquals("{!x}|b^2", params.get("foo")); + } + @Test public void testTargetingSelfIsHandledLocally() throws Exception { var req = systemInfoRequest(SolrRequest.METHOD.GET, params());