From 673ce3e5e16aae71d3a4833bcd1a9fa1dca8b4e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Thu, 18 Jun 2026 10:20:44 +0200 Subject: [PATCH 1/3] SOLR-18233 Strengthen Basic Authentication password policy and harden template users created by bin/solr auth enable (#4534) Backport of the take2 PR (apache/solr#4534) to branch_9x. - Reject password equal to username at login and at set-user time (API, Admin UI, CLI), with escape hatch system property solr.security.auth.basicauth.allowuseraspassword (env SOLR_SECURITY_AUTH_BASICAUTH_ALLOWUSERASPASSWORD). - bin/solr auth enable: ship template users (admin/index/search) with empty credentials, remove superadmin, only override blockUnknown when explicitly passed, and print a reminder to set passwords. - SecurityConfHandler now returns HTTP 400 on command errors via ApiBag.ExceptionWithErrObject (9x equivalent of main's SolrErrorWrappingException). - Admin UI security panel surfaces detailed command errors in its dialog. - Ref-guide: upgrade note added under "Solr 9.11"; control-script and basic-auth docs updated. --- ...n-Basic-Authentication-password-policy.yml | 7 ++ .../java/org/apache/solr/cli/AuthTool.java | 41 ++++++++++-- .../handler/admin/SecurityConfHandler.java | 4 +- .../Sha256AuthenticationProvider.java | 24 +++++++ solr/core/src/resources/security.json | 10 ++- .../org/apache/solr/cli/AuthToolTest.java | 49 +++++++++++++- .../cloud/TestQueryingOnDownCollection.java | 5 +- .../admin/SecurityConfHandlerTest.java | 23 ++++--- ...thWithShardHandlerFactoryOverrideTest.java | 10 +-- .../security/BasicAuthOnSingleNodeTest.java | 6 +- .../TestSha256AuthenticationProvider.java | 59 +++++++++++++++++ .../pages/basic-authentication-plugin.adoc | 8 ++- .../pages/solr-control-script-reference.adoc | 30 +++++++-- .../pages/major-changes-in-solr-9.adoc | 9 +++ .../solrj/io/stream/CloudAuthStreamTest.java | 66 +++++++++++-------- solr/webapp/web/js/angular/app.js | 14 +++- .../web/js/angular/controllers/security.js | 23 ++++++- 17 files changed, 312 insertions(+), 76 deletions(-) create mode 100644 changelog/unreleased/SOLR-18233-Strengthen-Basic-Authentication-password-policy.yml diff --git a/changelog/unreleased/SOLR-18233-Strengthen-Basic-Authentication-password-policy.yml b/changelog/unreleased/SOLR-18233-Strengthen-Basic-Authentication-password-policy.yml new file mode 100644 index 000000000000..68d8a849616d --- /dev/null +++ b/changelog/unreleased/SOLR-18233-Strengthen-Basic-Authentication-password-policy.yml @@ -0,0 +1,7 @@ +title: Strengthen Basic Authentication password policy (password must differ from username) and harden template users created by bin/solr auth enable. The check can be temporarily disabled with -Dsolr.security.auth.basicauth.allowuseraspassword=true (env SOLR_SECURITY_AUTH_BASICAUTH_ALLOWUSERASPASSWORD) as an upgrade escape hatch. +type: fixed +authors: + - name: Jan Høydahl +links: + - name: SOLR-18233 + url: https://issues.apache.org/jira/browse/SOLR-18233 diff --git a/solr/core/src/java/org/apache/solr/cli/AuthTool.java b/solr/core/src/java/org/apache/solr/cli/AuthTool.java index 9d6779d15861..355e0a7ce7b0 100644 --- a/solr/core/src/java/org/apache/solr/cli/AuthTool.java +++ b/solr/core/src/java/org/apache/solr/cli/AuthTool.java @@ -30,8 +30,10 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.stream.Collectors; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.DeprecatedAttributes; @@ -39,6 +41,7 @@ import org.apache.commons.cli.Option; import org.apache.lucene.util.Constants; import org.apache.solr.common.cloud.SolrZkClient; +import org.apache.solr.common.util.EnvUtils; import org.apache.solr.common.util.StrUtils; import org.apache.solr.core.SolrCore; import org.apache.solr.security.Sha256AuthenticationProvider; @@ -467,10 +470,14 @@ private int handleBasicAuth(CommandLine cli) throws Exception { } while (password.length() == 0); } - boolean blockUnknown = - Boolean.parseBoolean( - SolrCLI.getOptionWithDeprecatedAndDefault( - cli, "block-unknown", "blockUnknown", "true")); + if (username.equals(password) + && !EnvUtils.getPropertyAsBool( + Sha256AuthenticationProvider.ALLOW_USER_AS_PASSWORD_PROP, false)) { + CLIO.err( + "Error: username and password must not be identical." + + " This credential would never authenticate."); + runtime.exit(1); + } String resourceName = "security.json"; final URL resource = SolrCore.class.getClassLoader().getResource(resourceName); @@ -480,7 +487,14 @@ private int handleBasicAuth(CommandLine cli) throws Exception { ObjectMapper mapper = new ObjectMapper(); JsonNode securityJson1 = mapper.readTree(resource.openStream()); - ((ObjectNode) securityJson1).put("blockUnknown", blockUnknown); + // Only override blockUnknown if explicitly passed; otherwise let the template decide + if (cli.hasOption("block-unknown") || cli.hasOption("blockUnknown")) { + boolean blockUnknown = + Boolean.parseBoolean( + SolrCLI.getOptionWithDeprecatedAndDefault( + cli, "block-unknown", "blockUnknown", "true")); + ((ObjectNode) securityJson1.get("authentication")).put("blockUnknown", blockUnknown); + } JsonNode credentialsNode = securityJson1.get("authentication").get("credentials"); ((ObjectNode) credentialsNode) .put(username, Sha256AuthenticationProvider.getSaltedHashedValue(password)); @@ -534,8 +548,23 @@ private int handleBasicAuth(CommandLine cli) throws Exception { includeFile.toPath(), basicAuthConfFile.getAbsolutePath(), null, cli); final String successMessage = String.format( - Locale.ROOT, "Successfully enabled basic auth with username [%s].", username); + Locale.ROOT, + "Successfully enabled basic auth with username [%s] assigned to all roles (superadmin, admin, index, search).", + username); echo(successMessage); + if (!updateIncludeFileOnly) { + Map templateUsers = new LinkedHashMap<>(); + templateUsers.put("admin", "admin, index, search"); + templateUsers.put("index", "index, search"); + templateUsers.put("search", "search"); + templateUsers.remove(username); + CLIO.out( + "\nIMPORTANT: The following template users have been created with NO password set" + + " and cannot log in until passwords are assigned:"); + templateUsers.forEach((u, roles) -> CLIO.out(" - " + u + " (roles: " + roles + ")")); + CLIO.out( + "Set their passwords using the Admin UI Security page or the authentication API."); + } return 0; case "disable": diff --git a/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java index 0f3cc35d0624..3f5823da1314 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java @@ -136,8 +136,8 @@ private void doEdit( if (out == null) { List> errs = CommandOperation.captureErrors(commandsCopy); if (!errs.isEmpty()) { - rsp.add(CommandOperation.ERR_MSGS, errs); - return; + throw new ApiBag.ExceptionWithErrObject( + SolrException.ErrorCode.BAD_REQUEST, "error processing commands", errs); } log.debug("No edits made"); return; diff --git a/solr/core/src/java/org/apache/solr/security/Sha256AuthenticationProvider.java b/solr/core/src/java/org/apache/solr/security/Sha256AuthenticationProvider.java index ea00f1c4607e..567a48bc36d8 100644 --- a/solr/core/src/java/org/apache/solr/security/Sha256AuthenticationProvider.java +++ b/solr/core/src/java/org/apache/solr/security/Sha256AuthenticationProvider.java @@ -33,6 +33,7 @@ import org.apache.solr.api.AnnotatedApi; import org.apache.solr.api.Api; import org.apache.solr.common.util.CommandOperation; +import org.apache.solr.common.util.EnvUtils; import org.apache.solr.common.util.ValidatingJsonMap; import org.apache.solr.handler.admin.api.ModifyBasicAuthConfigAPI; import org.slf4j.Logger; @@ -43,6 +44,19 @@ public class Sha256AuthenticationProvider static String CANNOT_DELETE_LAST_USER_ERROR = "You cannot delete the last user. At least one user must be configured at all times."; + + /** + * System property (or {@code SOLR_SECURITY_AUTH_BASICAUTH_ALLOWUSERASPASSWORD} environment + * variable) that, when explicitly set to {@code true}, disables the check that rejects a password + * equal to its username, both at login time and when creating or editing a user via the {@code + * set-user} command (UI, API or CLI). This is an escape hatch for users upgrading to 9.11.0 or + * 10.1.0 who still have Basic Auth users with weak passwords equal to the username; it lets them + * keep logging in (and managing those accounts) while they migrate to stronger passwords. + * Defaults to {@code false}. + */ + public static final String ALLOW_USER_AS_PASSWORD_PROP = + "solr.security.auth.basicauth.allowuseraspassword"; + private Map credentials; private String realm; private Map promptHeader; @@ -94,6 +108,11 @@ public void init(Map pluginConfig) { @Override public boolean authenticate(String username, String password) { + if (username != null + && username.equals(password) + && !EnvUtils.getPropertyAsBool(ALLOW_USER_AS_PASSWORD_PROP, false)) { + return false; + } String cred = credentials.get(username); if (cred == null || cred.isEmpty()) return false; cred = cred.trim(); @@ -166,6 +185,11 @@ public Map edit(Map latestConf, List handler.handleRequestBody(badReq, badRsp)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue(ex.getMessage().contains("method is not a valid key for the permission")); handler.close(); } diff --git a/solr/core/src/test/org/apache/solr/security/AuthWithShardHandlerFactoryOverrideTest.java b/solr/core/src/test/org/apache/solr/security/AuthWithShardHandlerFactoryOverrideTest.java index eff43b40d226..754a1ffdb7ab 100644 --- a/solr/core/src/test/org/apache/solr/security/AuthWithShardHandlerFactoryOverrideTest.java +++ b/solr/core/src/test/org/apache/solr/security/AuthWithShardHandlerFactoryOverrideTest.java @@ -38,7 +38,7 @@ public class AuthWithShardHandlerFactoryOverrideTest extends SolrCloudAuthTestCa + " \"authentication\":{\n" + " \"blockUnknown\": true,\n" + " \"class\":\"solr.BasicAuthPlugin\",\n" - + " \"credentials\":{\"solr\":\"EEKn7ywYk5jY8vG9TyqlG2jvYuvh1Q7kCCor6Hqm320= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw=\"}\n" + + " \"credentials\":{\"solr\":\"JeRyxP8A3dVWhFgFbf/Eg2RXmuoU8BE5gbNQyxmGAJQ= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw=\"}\n" + " },\n" + " \"authorization\":{\n" + " \"class\":\"solr.RuleBasedAuthorizationPlugin\",\n" @@ -56,11 +56,11 @@ public void setupCluster() throws Exception { .withSecurityJson(SECURITY_CONF) .configure(); CollectionAdminRequest.createCollection(COLLECTION, "conf", 4, 1) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(cluster.getSolrClient()); CollectionAdminRequest.createAlias(ALIAS, COLLECTION) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(cluster.getSolrClient()); cluster.waitForActiveCollection(COLLECTION, 4, 4); @@ -89,7 +89,7 @@ public void collectionTest() throws Exception { for (int i = 0; i < 30; i++) { SolrResponse response = new QueryRequest(params("q", "*:*")) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(client, COLLECTION); // likely to be non-null, even if an error occurred assertNotNull(response); @@ -109,7 +109,7 @@ public void aliasTest() throws Exception { for (int i = 0; i < 30; i++) { SolrResponse response = new QueryRequest(params("q", "*:*")) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(client, ALIAS); // likely to be non-null, even if an error occurred assertNotNull(response); diff --git a/solr/core/src/test/org/apache/solr/security/BasicAuthOnSingleNodeTest.java b/solr/core/src/test/org/apache/solr/security/BasicAuthOnSingleNodeTest.java index 8b6708fe825d..d867c5ed96b9 100644 --- a/solr/core/src/test/org/apache/solr/security/BasicAuthOnSingleNodeTest.java +++ b/solr/core/src/test/org/apache/solr/security/BasicAuthOnSingleNodeTest.java @@ -37,7 +37,7 @@ public void setupCluster() throws Exception { .withSecurityJson(STD_CONF) .configure(); CollectionAdminRequest.createCollection(COLLECTION, "conf", 4, 1) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(cluster.getSolrClient()); cluster.waitForActiveCollection(COLLECTION, 4, 4); } @@ -60,7 +60,7 @@ public void basicTest() throws Exception { for (int i = 0; i < 30; i++) { assertNotNull( new QueryRequest(params("q", "*:*")) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(client, COLLECTION)); } } @@ -106,7 +106,7 @@ public void testDeleteSecurityJsonZnode() throws Exception { + " \"authentication\":{\n" + " \"blockUnknown\": true,\n" + " \"class\":\"solr.BasicAuthPlugin\",\n" - + " \"credentials\":{\"solr\":\"EEKn7ywYk5jY8vG9TyqlG2jvYuvh1Q7kCCor6Hqm320= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw=\"}\n" + + " \"credentials\":{\"solr\":\"JeRyxP8A3dVWhFgFbf/Eg2RXmuoU8BE5gbNQyxmGAJQ= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw=\"}\n" + " },\n" + " \"authorization\":{\n" + " \"class\":\"solr.RuleBasedAuthorizationPlugin\",\n" diff --git a/solr/core/src/test/org/apache/solr/security/TestSha256AuthenticationProvider.java b/solr/core/src/test/org/apache/solr/security/TestSha256AuthenticationProvider.java index 1646f2a772e6..c1bdfdab1316 100644 --- a/solr/core/src/test/org/apache/solr/security/TestSha256AuthenticationProvider.java +++ b/solr/core/src/test/org/apache/solr/security/TestSha256AuthenticationProvider.java @@ -22,6 +22,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.common.util.CommandOperation; @@ -106,6 +107,64 @@ public void testBasicAuthDeleteFinalUser() throws IOException { } } + public void testAuthenticateRejectsUsernameEqualPassword() { + // Simulate a credential store that has the username's own hash as the password + // (e.g. set up before this policy was in effect) and verify authenticate() still rejects it. + String user = "alice"; + String hashedValue = Sha256AuthenticationProvider.getSaltedHashedValue(user); + Map config = new HashMap<>(); + Map credentials = new HashMap<>(); + credentials.put(user, hashedValue); + config.put("credentials", credentials); + + Sha256AuthenticationProvider provider = new Sha256AuthenticationProvider(); + provider.init(config); + assertFalse( + "authenticate() must reject username==password even when hash matches", + provider.authenticate(user, user)); + } + + public void testAllowUserAsPasswordSyspropReenablesLogin() { + String user = "alice"; + String hashedValue = Sha256AuthenticationProvider.getSaltedHashedValue(user); + Map config = new HashMap<>(); + Map credentials = new HashMap<>(); + credentials.put(user, hashedValue); + config.put("credentials", credentials); + + Sha256AuthenticationProvider provider = new Sha256AuthenticationProvider(); + provider.init(config); + + System.setProperty(Sha256AuthenticationProvider.ALLOW_USER_AS_PASSWORD_PROP, "true"); + try { + assertTrue( + "authenticate() must allow username==password when escape-hatch sysprop is true", + provider.authenticate(user, user)); + assertFalse( + "authenticate() must still reject a genuinely wrong password", + provider.authenticate(user, "WrongPassword")); + + // The escape hatch also relaxes set-user, so username==password is accepted there. + Map latestConf = createConfigMap("ignore", "me"); + CommandOperation cmd = new CommandOperation("set-user", Map.of(user, user)); + provider.edit(latestConf, List.of(cmd)); + assertFalse( + "set-user should allow username==password when escape hatch is enabled", cmd.hasError()); + } finally { + System.clearProperty(Sha256AuthenticationProvider.ALLOW_USER_AS_PASSWORD_PROP); + } + } + + public void testSetUserRejectsUsernameEqualPassword() { + Sha256AuthenticationProvider provider = new Sha256AuthenticationProvider(); + provider.init(createConfigMap("ignore", "me")); + Map latestConf = createConfigMap("ignore", "me"); + String user = "bob"; + CommandOperation cmd = new CommandOperation("set-user", Map.of(user, user)); + provider.edit(latestConf, List.of(cmd)); + assertTrue("set-user should report an error when username==password", cmd.hasError()); + } + private Map createConfigMap(String user, String pw) { Map config = new HashMap<>(); Map credentials = new HashMap<>(); diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc index 4687fe5f6c09..f54c58093fbc 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc @@ -24,10 +24,11 @@ To control user permissions, you may need to configure an authorization plugin a == Enable Basic Authentication -To use Basic authentication, you must first create a `security.json` file. -This file and where to put it is described in detail in the section xref:authentication-and-authorization-plugins.adoc#configuring-security-json[Configuring security.json]. +When running in cloud mode, Basic authentication can be enabled from the command line using the `bin/solr auth enable` command, which applies a best-practice security template with pre-configured roles and permissions. +See xref:solr-control-script-reference.adoc#enabling-basic-authentication[Enabling Basic Authentication] in the Solr Control Script Reference for details. -If running in cloud mode, you can use the `bin/solr auth` command-line utility to enable security for a new installation, see: `bin/solr auth --help` for more details. +Alternatively, you can create the `security.json` file manually. +This file and where to put it is described in detail in the section xref:authentication-and-authorization-plugins.adoc#configuring-security-json[Configuring security.json]. For Basic authentication, `security.json` must have an `authentication` block which defines the class being used for authentication. Usernames and passwords (Format: `base64(sha256(sha256(salt+password))) base64(salt)`) could be added when the file is created, or can be added later with the Authentication API, described below. @@ -150,6 +151,7 @@ If users need to be restricted to a specific collection, that can be done with t === Add a User or Edit a Password The `set-user` command allows you to add users and change their passwords. +Passwords must not be identical to the username. For example, the following defines two users and their passwords: [tabs#set-user] diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc index c6e66f7a8805..053b6c06f11d 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc @@ -1022,7 +1022,7 @@ The `bin/solr` script allows enabling or disabling Authentication, allowing you Currently this command is only available when using SolrCloud mode and must be run on the machine hosting Solr. -For Basic Authentication the script provides https://github.com/apache/solr/blob/branch_9x/solr/core/src/resources/security.json[user roles and permission mappings], and maps the created user to the `superadmin` role. +For Basic Authentication the script provides https://github.com/apache/solr/blob/branch_9x/solr/core/src/resources/security.json[user roles and permission mappings], and maps the created user to all roles (`superadmin`, `admin`, `index`, `search`). For Kerberos it only enables the security.json, it doesn't set up any users or role mappings. @@ -1030,12 +1030,16 @@ For Kerberos it only enables the security.json, it doesn't set up any users or r The command `bin/solr auth enable` configures Solr to use Basic Authentication when accessing the User Interface, using `bin/solr` and any API requests. +NOTE: This command currently requires SolrCloud mode — it uploads the generated `security.json` to ZooKeeper so that all nodes pick it up automatically. +For user-managed (standalone) clusters, you must create the `security.json` file manually and place it in each node's Solr home directory. +See xref:basic-authentication-plugin.adoc[] for details. + TIP: For more information about Solr's authentication plugins, see the section xref:securing-solr.adoc[]. For more information on Basic Authentication support specifically, see the section xref:basic-authentication-plugin.adoc[]. The `bin/solr auth enable` command makes several changes to enable Basic Authentication: -* Take the base https://github.com/apache/solr/blob/main/solr/core/resources/security.json[security.json] file, evolves it using `auth` command parameters, and uploads the new file to ZooKeeper. +* Takes the base https://github.com/apache/solr/blob/branch_9x/solr/core/src/resources/security.json[security.json] template with best-practice roles and permissions, applies `auth` command parameters, and uploads the result to ZooKeeper. + * Adds two lines to `bin/solr.in.sh` or `bin\solr.in.cmd` to set the authentication type, and the path to `basicAuth.conf`: + @@ -1047,6 +1051,19 @@ SOLR_AUTHENTICATION_OPTS="-Dsolr.httpclient.config=/path/to/solr-{solr-full-vers ---- * Creates the file `server/solr/basicAuth.conf` to store the credential information that is used with `bin/solr` commands. +In addition to the operator-created user, the command also creates three template users with predefined role assignments. +These users have no password set and cannot log in until passwords are explicitly assigned: + +[cols="1,2",options="header"] +|=== +|Username |Roles +|`admin` |admin, index, search +|`index` |index, search +|`search` |search +|=== + +After enabling Basic Authentication, set passwords for these template users using the Admin UI Security page or the xref:basic-authentication-plugin.adoc#add-a-user-or-edit-a-password[authentication API]. + Here are some example usages: [source,plain] @@ -1089,11 +1106,14 @@ Either `--credentials` or `--prompt` *must* be specified. + [%autowidth,frame=none] |=== -|Optional |Default: `true` +|Optional |Default: use value from `security.json` template (`false`) |=== + -When `true`, this blocks out access to unauthenticated users from accessing Solr. -When `false`, unauthenticated users will still be able to access Solr, but only for operations not explicitly requiring a user role in the Authorization plugin configuration. +Controls whether unauthenticated requests are blocked. +The default `security.json` template sets `blockUnknown` to `false` because it includes a `RuleBasedAuthorizationPlugin` with fine-grained permissions — unauthenticated users can only access endpoints explicitly granted to the `null` role (by default *health* and *metrics-read*, left open so that load balancers and monitoring tools can operate without credentials). +All other operations require an authenticated user with the appropriate role. ++ +If you want to require authentication for _all_ requests (including health checks and metrics), pass `--block-unknown true` explicitly. `--solr-include-file `:: + diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc index a9fde5d46967..82c00b66e2c1 100644 --- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc +++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc @@ -103,6 +103,15 @@ The `blockUnknown` setting in the JWT Authentication plugin now defaults to `tru In Solr 9.10 and earlier, the code default was `false` (pass-through), which contradicted the reference guide documentation that described `true` as the default. Users upgrading from 9.10 who relied on the pass-through behavior must explicitly set `"blockUnknown": false` in their `security.json`. +=== Security + +The Basic Authentication password policy has been strengthened: a password may no longer be equal to its username. +Login attempts where the password equals the username are now rejected, and creating or editing such a user (via the Admin UI, the Authentication API, or `bin/solr auth`) is no longer allowed. + +If you are upgrading and still have Basic Auth users whose password equals their username, you can temporarily re-enable the old behavior by setting the system property `-Dsolr.security.auth.basicauth.allowuseraspassword=true` (or the environment variable `SOLR_SECURITY_AUTH_BASICAUTH_ALLOWUSERASPASSWORD=true`). +When enabled, this escape hatch relaxes both the login-time check and the user creation/editing check, so existing accounts keep working and can still be managed. +It is intended as a temporary measure while you migrate the affected accounts to stronger passwords, and should be removed once that is done. + == Solr 9.10 === SolrJ diff --git a/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/CloudAuthStreamTest.java b/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/CloudAuthStreamTest.java index bcda78870c22..7567314dff3a 100644 --- a/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/CloudAuthStreamTest.java +++ b/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/CloudAuthStreamTest.java @@ -66,16 +66,24 @@ public class CloudAuthStreamTest extends SolrCloudTestCase { private static String solrUrl = null; + /** + * Helper: every user's password is derived from their username via this method. The password must + * differ from the username (Solr's Basic Auth policy rejects passwords equal to the username). + */ + private static String passwordFor(final String user) { + return user + "_password"; + } + /** * Helper that returns the original {@link SolrRequest} with its original type so it can - * be chained. This method knows that for the purpose of this test, every username is its own - * password + * be chained. This method knows that for the purpose of this test, every user's password is + * derived from their username via {@link #passwordFor}. * * @see SolrRequest#setBasicAuthCredentials */ private static > T setBasicAuthCredentials(T req, String user) { assertNotNull(user); - req.setBasicAuthCredentials(user, user); + req.setBasicAuthCredentials(user, passwordFor(user)); return req; } @@ -83,9 +91,11 @@ private static > T setBasicAuthCredentials(T req, Strin public static void setupCluster() throws Exception { final List users = Arrays.asList(READ_ONLY_USER, WRITE_X_USER, WRITE_Y_USER, ADMIN_USER); - // For simplicity: every user uses a password the same as their name... + // For simplicity: every user's password is derived from their name via passwordFor()... final Map credentials = - users.stream().collect(Collectors.toMap(Function.identity(), s -> getSaltedHashedValue(s))); + users.stream() + .collect( + Collectors.toMap(Function.identity(), s -> getSaltedHashedValue(passwordFor(s)))); // For simplicity: Every user is their own role... final Map roles = @@ -125,7 +135,7 @@ public static void setupCluster() throws Exception { for (String collection : Arrays.asList(COLLECTION_X, COLLECTION_Y)) { CollectionAdminRequest.createCollection(collection, "_default", 2, 2) .setPerReplicaState(SolrCloudTestCase.USE_PER_REPLICA_STATE) - .setBasicAuthCredentials(ADMIN_USER, ADMIN_USER) + .setBasicAuthCredentials(ADMIN_USER, passwordFor(ADMIN_USER)) .process(cluster.getSolrClient()); } @@ -248,7 +258,7 @@ public void testEchoStream() throws Exception { params( "qt", "/stream", "expr", "echo(hello world)")); - solrStream.setCredentials(READ_ONLY_USER, READ_ONLY_USER); + solrStream.setCredentials(READ_ONLY_USER, passwordFor(READ_ONLY_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals("hello world", tuples.get(0).get("echo")); @@ -297,7 +307,7 @@ public void testSimpleUpdateStream() throws Exception { "/stream", "expr", "update(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42,a_i=1,b_i=5))")); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(1L, tuples.get(0).get("totalIndexed")); @@ -340,7 +350,7 @@ public void testSimpleUpdateStreamInsufficientCredentials() throws Exception { "expr", "update(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42,a_i=1,b_i=5))")); - solrStream.setCredentials(user, user); + solrStream.setCredentials(user, passwordFor(user)); // NOTE: Can't make any assertions about Exception: SOLR-14226 expectThrows( @@ -363,7 +373,7 @@ public void testIndirectUpdateStream() throws Exception { "/stream", "expr", "update(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42,a_i=1,b_i=5))")); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(1L, tuples.get(0).get("totalIndexed")); @@ -392,7 +402,7 @@ public void testIndirectUpdateStream() throws Exception { new SolrStream( solrUrl + "/" + COLLECTION_Y, // NOTE: Y route params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(10L, tuples.get(0).get("batchIndexed")); @@ -412,7 +422,7 @@ public void testIndirectUpdateStream() throws Exception { new SolrStream( solrUrl + "/" + COLLECTION_X, // NOTE: X route params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(3, tuples.size()); @@ -441,7 +451,7 @@ public void testIndirectUpdateStreamInsufficientCredentials() throws Exception { "/stream", "expr", "update(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42,a_i=1,b_i=5))")); - solrStream.setCredentials(WRITE_Y_USER, WRITE_Y_USER); + solrStream.setCredentials(WRITE_Y_USER, passwordFor(WRITE_Y_USER)); // NOTE: Can't make any assertions about Exception: SOLR-14226 expectThrows( @@ -461,7 +471,7 @@ public void testExecutorUpdateStream() throws Exception { + ", batchSize=5, tuple(id=42,a_i=1,b_i=5))\"))"; final SolrStream solrStream = new SolrStream(solrUrl + "/" + COLLECTION_X, params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(0, tuples.size()); @@ -488,7 +498,7 @@ public void testExecutorUpdateStreamInsufficientCredentials() throws Exception { new SolrStream( solrUrl + "/" + path, params("qt", "/stream", "_trace", "executor_via_" + trace, "expr", expr)); - solrStream.setCredentials(user, user); + solrStream.setCredentials(user, passwordFor(user)); // NOTE: Because of the background threads, no failures will to be returned to client... final List tuples = getTuples(solrStream); @@ -520,7 +530,7 @@ public void testDaemonUpdateStream() throws Exception { + ",tuple(id=42,a_i=1,b_i=5)))"; final SolrStream solrStream = new SolrStream(daemonUrl, params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); // daemon starting status } @@ -535,7 +545,7 @@ public void testDaemonUpdateStream() throws Exception { params( "qt", "/stream", "action", "list")); - daemonCheck.setCredentials(WRITE_X_USER, WRITE_X_USER); + daemonCheck.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(daemonCheck); assertEquals(1, tuples.size()); // our daemon; iterations = tuples.get(0).getLong("iterations"); @@ -557,7 +567,7 @@ public void testDaemonUpdateStream() throws Exception { "qt", "/stream", "action", "kill", "id", "daemonId")); - daemonKiller.setCredentials(WRITE_X_USER, WRITE_X_USER); + daemonKiller.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(daemonKiller); assertEquals(1, tuples.size()); // daemon death status } @@ -584,7 +594,7 @@ public void testDaemonUpdateStreamInsufficientCredentials() throws Exception { final SolrStream solrStream = new SolrStream( daemonUrl, params("qt", "/stream", "_trace", "start_" + daemonId, "expr", expr)); - solrStream.setCredentials(user, user); + solrStream.setCredentials(user, passwordFor(user)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); // daemon starting status } @@ -600,7 +610,7 @@ public void testDaemonUpdateStreamInsufficientCredentials() throws Exception { "qt", "/stream", "_trace", "check_" + daemonId, "action", "list")); - daemonCheck.setCredentials(user, user); + daemonCheck.setCredentials(user, passwordFor(user)); final List tuples = getTuples(daemonCheck); assertEquals(1, tuples.size()); // our daemon; if (log.isInfoEnabled()) { @@ -633,7 +643,7 @@ public void testDaemonUpdateStreamInsufficientCredentials() throws Exception { "kill", "id", daemonId)); - daemonKiller.setCredentials(user, user); + daemonKiller.setCredentials(user, passwordFor(user)); final List tuples = getTuples(daemonKiller); assertEquals(1, tuples.size()); // daemon death status } @@ -665,7 +675,7 @@ public void testSimpleDeleteStream() throws Exception { "/stream", "expr", "delete(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42))")); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(1L, tuples.get(0).get("totalIndexed")); @@ -699,7 +709,7 @@ public void testSimpleDeleteStreamByQuery() throws Exception { final SolrStream solrStream = new SolrStream(solrUrl + "/" + COLLECTION_X, params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(2, tuples.size()); assertEquals(5L, tuples.get(0).get("totalIndexed")); @@ -760,7 +770,7 @@ public void testSimpleDeleteStreamInsufficientCredentials() throws Exception { "expr", "update(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42))")); - solrStream.setCredentials(user, user); + solrStream.setCredentials(user, passwordFor(user)); // NOTE: Can't make any assertions about Exception: SOLR-14226 expectThrows( @@ -803,7 +813,7 @@ public void testIndirectDeleteStream() throws Exception { "/stream", "expr", "delete(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42z))")); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(1L, tuples.get(0).get("totalIndexed")); @@ -827,7 +837,7 @@ public void testIndirectDeleteStream() throws Exception { new SolrStream( solrUrl + "/" + COLLECTION_Y, // NOTE: Y route params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(10L, tuples.get(0).get("batchIndexed")); @@ -851,7 +861,7 @@ public void testIndirectDeleteStream() throws Exception { new SolrStream( solrUrl + "/" + COLLECTION_X, // NOTE: X route params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(3, tuples.size()); @@ -890,7 +900,7 @@ public void testIndirectDeleteStreamInsufficientCredentials() throws Exception { "/stream", "expr", "delete(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42))")); - solrStream.setCredentials(WRITE_Y_USER, WRITE_Y_USER); + solrStream.setCredentials(WRITE_Y_USER, passwordFor(WRITE_Y_USER)); // NOTE: Can't make any assertions about Exception: SOLR-14226 expectThrows( diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index 0cb3bf529b9f..cb078c9993cc 100644 --- a/solr/webapp/web/js/angular/app.js +++ b/solr/webapp/web/js/angular/app.js @@ -376,6 +376,10 @@ solrAdminApp.config([ if ($rootScope.exceptions[config.url]) { delete $rootScope.exceptions[config.url]; } + if (config.url && config.url.startsWith("/api/cluster/security/")) { + // Clear any stale security panel error when a new security request starts + $rootScope.$broadcast('securityApiClearError'); + } activeRequests++; if (sessionStorage.getItem("auth.header")) { config.headers['Authorization'] = sessionStorage.getItem("auth.header"); @@ -417,8 +421,9 @@ solrAdminApp.config([ return rejection; } - // Schema Designer handles errors internally to provide a better user experience than the global error handler + // Schema Designer and Security panels handle errors internally to provide a better user experience than the global error handler var isHandledBySchemaDesigner = rejection.config.url && rejection.config.url.startsWith("/api/schema-designer/"); + var isHandledBySecurity = rejection.config.url && rejection.config.url.startsWith("/api/cluster/security/"); if (rejection.status === 0) { $rootScope.$broadcast('connectionStatusActive'); if (!$rootScope.retryCount) $rootScope.retryCount=0; @@ -450,6 +455,9 @@ solrAdminApp.config([ } else if (rejection.status === 403 && !isHandledBySchemaDesigner) { // No permission $rootScope.showAuthzFailures = true; + } else if (isHandledBySecurity) { + // Let the security panel surface the detailed error in its own dialog + $rootScope.$broadcast('securityApiError', rejection); } else { // schema designer prefers to handle errors itself if (!isHandledBySchemaDesigner) { @@ -461,10 +469,12 @@ solrAdminApp.config([ return {request: started, response: ended, responseError: failed}; }) -.config(function($httpProvider) { +.config(function($httpProvider, $qProvider) { $httpProvider.interceptors.push("httpInterceptor"); // Force BasicAuth plugin to serve us a 'Authorization: xBasic xxxx' header so browser will not pop up login dialogue $httpProvider.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; + // Suppress AngularJS 1.6+ "Possibly unhandled rejection" console noise; errors are handled via callbacks and the security/schema-designer error dialogs + $qProvider.errorOnUnhandledRejections(false); }) .directive('fileModel', function ($parse) { return { diff --git a/solr/webapp/web/js/angular/controllers/security.js b/solr/webapp/web/js/angular/controllers/security.js index fd65a289988f..776116eedd52 100644 --- a/solr/webapp/web/js/angular/controllers/security.js +++ b/solr/webapp/web/js/angular/controllers/security.js @@ -160,14 +160,31 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki $scope.errorHandler = function (e) { var error = e.data && e.data.error ? e.data.error : null; if (error && error.msg) { - $scope.securityAPIError = error.msg; - $scope.securityAPIErrorDetails = e.data.errorDetails; + var allMessages = []; + if (error.details && Array.isArray(error.details)) { + error.details.forEach(function(detail) { + if (detail.errorMessages && Array.isArray(detail.errorMessages)) { + allMessages = allMessages.concat(detail.errorMessages); + } + }); + } + $scope.securityAPIError = allMessages.length > 0 ? allMessages[0] : error.msg; + $scope.securityAPIErrorDetails = allMessages.length > 1 ? allMessages.slice(1).join("\n") : null; } else if (e.data && e.data.message) { $scope.securityAPIError = e.data.message; - $scope.securityAPIErrorDetails = JSON.stringify(e.data); + $scope.securityAPIErrorDetails = null; } }; + // Security API command errors (HTTP 4xx/5xx) are broadcast by the global httpInterceptor; + // show them in this panel's error dialog rather than the generic page-header banner. + $scope.$on('securityApiError', function (event, rejection) { + $scope.errorHandler(rejection); + }); + $scope.$on('securityApiClearError', function () { + $scope.closeErrorDialog(); + }); + $scope.showHelp = function (id) { if ($scope.helpId && ($scope.helpId === id || id === '')) { delete $scope.helpId; From 06e59ef46e651215635ae3f5f9af28acba6ec0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Thu, 18 Jun 2026 13:15:49 +0200 Subject: [PATCH 2/3] Use "Basic AUthentication" as paragrap heading in major-changes --- .../modules/upgrade-notes/pages/major-changes-in-solr-9.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc index 82c00b66e2c1..3ff3e72fc1bf 100644 --- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc +++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc @@ -103,7 +103,7 @@ The `blockUnknown` setting in the JWT Authentication plugin now defaults to `tru In Solr 9.10 and earlier, the code default was `false` (pass-through), which contradicted the reference guide documentation that described `true` as the default. Users upgrading from 9.10 who relied on the pass-through behavior must explicitly set `"blockUnknown": false` in their `security.json`. -=== Security +=== Basic Authentication The Basic Authentication password policy has been strengthened: a password may no longer be equal to its username. Login attempts where the password equals the username are now rejected, and creating or editing such a user (via the Admin UI, the Authentication API, or `bin/solr auth`) is no longer allowed. From a97a7efe7160e8d52a124f21fcf8643790cd6690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Thu, 18 Jun 2026 13:46:08 +0200 Subject: [PATCH 3/3] Copilot review fix --- solr/webapp/web/js/angular/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index cb078c9993cc..c8176396dd36 100644 --- a/solr/webapp/web/js/angular/app.js +++ b/solr/webapp/web/js/angular/app.js @@ -452,7 +452,7 @@ solrAdminApp.config([ sessionStorage.setItem("auth.location", $location.path()); $location.path('/login'); } - } else if (rejection.status === 403 && !isHandledBySchemaDesigner) { + } else if (rejection.status === 403 && !isHandledBySchemaDesigner && !isHandledBySecurity) { // No permission $rootScope.showAuthzFailures = true; } else if (isHandledBySecurity) {