Moderate severityNVD Advisory· Published May 4, 2020· Updated Aug 4, 2024
CVE-2020-10686
CVE-2020-10686
Description
A flaw was found in Keycloak version 8.0.2 and 9.0.0, and was fixed in Keycloak version 9.0.1, where a malicious user registers as oneself. The attacker could then use the remove devices form to post different credential IDs and possibly remove MFA devices for other users.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.keycloak:keycloak-coreMaven | < 9.0.2 | 9.0.2 |
Affected products
1Patches
19 files changed · +226 −11
model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserCredentialStore.java+7 −3 modified@@ -57,7 +57,7 @@ public JpaUserCredentialStore(KeycloakSession session, EntityManager em) { @Override public void updateCredential(RealmModel realm, UserModel user, CredentialModel cred) { CredentialEntity entity = em.find(CredentialEntity.class, cred.getId()); - if (entity == null) return; + if (!checkCredentialEntity(entity, user)) return; entity.setCreatedDate(cred.getCreatedDate()); entity.setUserLabel(cred.getUserLabel()); entity.setType(cred.getType()); @@ -80,7 +80,7 @@ public boolean removeStoredCredential(RealmModel realm, UserModel user, String i @Override public CredentialModel getStoredCredentialById(RealmModel realm, UserModel user, String id) { CredentialEntity entity = em.find(CredentialEntity.class, id); - if (entity == null) return null; + if (!checkCredentialEntity(entity, user)) return null; CredentialModel model = toModel(entity); return model; } @@ -161,7 +161,7 @@ CredentialEntity createCredentialEntity(RealmModel realm, UserModel user, Creden CredentialEntity removeCredentialEntity(RealmModel realm, UserModel user, String id) { CredentialEntity entity = em.find(CredentialEntity.class, id, LockModeType.PESSIMISTIC_WRITE); - if (entity == null) return null; + if (!checkCredentialEntity(entity, user)) return null; int currentPriority = entity.getPriority(); @@ -234,4 +234,8 @@ public boolean moveCredentialTo(RealmModel realm, UserModel user, String id, Str return true; } + private boolean checkCredentialEntity(CredentialEntity entity, UserModel user) { + return entity != null && entity.getUser() != null && entity.getUser().getId().equals(user.getId()); + } + }
model/jpa/src/main/java/org/keycloak/storage/jpa/JpaUserFederatedStorageProvider.java+7 −3 modified@@ -568,7 +568,7 @@ public void deleteRoleMapping(RealmModel realm, String userId, RoleModel role) { @Override public void updateCredential(RealmModel realm, String userId, CredentialModel cred) { FederatedUserCredentialEntity entity = em.find(FederatedUserCredentialEntity.class, cred.getId()); - if (entity == null) return; + if (!checkCredentialEntity(entity, userId)) return; createIndex(realm, userId); entity.setCreatedDate(cred.getCreatedDate()); entity.setType(cred.getType()); @@ -605,7 +605,7 @@ public CredentialModel createCredential(RealmModel realm, String userId, Credent @Override public boolean removeStoredCredential(RealmModel realm, String userId, String id) { FederatedUserCredentialEntity entity = em.find(FederatedUserCredentialEntity.class, id, LockModeType.PESSIMISTIC_WRITE); - if (entity == null) return false; + if (!checkCredentialEntity(entity, userId)) return false; int currentPriority = entity.getPriority(); @@ -625,11 +625,15 @@ public boolean removeStoredCredential(RealmModel realm, String userId, String id @Override public CredentialModel getStoredCredentialById(RealmModel realm, String userId, String id) { FederatedUserCredentialEntity entity = em.find(FederatedUserCredentialEntity.class, id); - if (entity == null) return null; + if (!checkCredentialEntity(entity, userId)) return null; CredentialModel model = toModel(entity); return model; } + private boolean checkCredentialEntity(FederatedUserCredentialEntity entity, String userId) { + return entity != null && entity.getUserId() != null && entity.getUserId().equals(userId); + } + protected CredentialModel toModel(FederatedUserCredentialEntity entity) { CredentialModel model = new CredentialModel(); model.setId(entity.getId());
services/src/main/java/org/keycloak/services/resources/account/AccountCredentialResource.java+24 −3 modified@@ -1,6 +1,7 @@ package org.keycloak.services.resources.account; import com.fasterxml.jackson.annotation.JsonIgnore; +import org.jboss.logging.Logger; import org.jboss.resteasy.annotations.cache.NoCache; import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.AuthenticatorFactory; @@ -17,13 +18,16 @@ import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.services.ErrorResponse; +import org.keycloak.services.ErrorResponseException; import org.keycloak.services.managers.Auth; import org.keycloak.services.messages.Messages; +import org.keycloak.util.JsonSerialization; import org.keycloak.utils.MediaType; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; +import javax.ws.rs.NotFoundException; import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; @@ -45,6 +49,8 @@ public class AccountCredentialResource { + private static final Logger logger = Logger.getLogger(AccountCredentialResource.class); + public static final String TYPE = "type"; public static final String ENABLED_ONLY = "enabled-only"; public static final String USER_CREDENTIALS = "user-credentials"; @@ -279,6 +285,10 @@ private boolean isFlowEffectivelyDisabled(AuthenticationFlowModel flow) { @NoCache public void removeCredential(final @PathParam("credentialId") String credentialId) { auth.require(AccountRoles.MANAGE_ACCOUNT); + CredentialModel credential = session.userCredentialManager().getStoredCredentialById(realm, user, credentialId); + if (credential == null) { + throw new NotFoundException("Credential not found"); + } session.userCredentialManager().removeStoredCredential(realm, user, credentialId); } @@ -287,14 +297,25 @@ public void removeCredential(final @PathParam("credentialId") String credentialI * Update a user label of specified credential of current user * * @param credentialId ID of the credential, which will be updated - * @param userLabel new user label + * @param userLabel new user label as JSON string */ @PUT - @Consumes(javax.ws.rs.core.MediaType.TEXT_PLAIN) + @Consumes(MediaType.APPLICATION_JSON) @Path("{credentialId}/label") + @NoCache public void setLabel(final @PathParam("credentialId") String credentialId, String userLabel) { auth.require(AccountRoles.MANAGE_ACCOUNT); - session.userCredentialManager().updateCredentialLabel(realm, user, credentialId, userLabel); + CredentialModel credential = session.userCredentialManager().getStoredCredentialById(realm, user, credentialId); + if (credential == null) { + throw new NotFoundException("Credential not found"); + } + + try { + String label = JsonSerialization.readValue(userLabel, String.class); + session.userCredentialManager().updateCredentialLabel(realm, user, credentialId, label); + } catch (IOException ioe) { + throw new ErrorResponseException(ErrorResponse.error(Messages.INVALID_REQUEST, Response.Status.BAD_REQUEST)); + } } // TODO: This is kept here for now and commented.
services/src/main/java/org/keycloak/services/resources/admin/UserResource.java+8 −2 modified@@ -662,6 +662,12 @@ public List<String> getConfiguredUserStorageCredentialTypes() { @NoCache public void removeCredential(final @PathParam("credentialId") String credentialId) { auth.users().requireManage(user); + CredentialModel credential = session.userCredentialManager().getStoredCredentialById(realm, user, credentialId); + if (credential == null) { + // we do this to make sure somebody can't phish ids + if (auth.users().canQuery()) throw new NotFoundException("Credential not found"); + else throw new ForbiddenException(); + } session.userCredentialManager().removeStoredCredential(realm, user, credentialId); adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).success(); } @@ -677,7 +683,7 @@ public void setCredentialUserLabel(final @PathParam("credentialId") String crede CredentialModel credential = session.userCredentialManager().getStoredCredentialById(realm, user, credentialId); if (credential == null) { // we do this to make sure somebody can't phish ids - if (auth.users().canQuery()) throw new NotFoundException("User not found"); + if (auth.users().canQuery()) throw new NotFoundException("Credential not found"); else throw new ForbiddenException(); } session.userCredentialManager().updateCredentialLabel(realm, user, credentialId, userLabel); @@ -705,7 +711,7 @@ public void moveCredentialAfter(final @PathParam("credentialId") String credenti CredentialModel credential = session.userCredentialManager().getStoredCredentialById(realm, user, credentialId); if (credential == null) { // we do this to make sure somebody can't phish ids - if (auth.users().canQuery()) throw new NotFoundException("User not found"); + if (auth.users().canQuery()) throw new NotFoundException("Credential not found"); else throw new ForbiddenException(); } session.userCredentialManager().moveCredentialTo(realm, user, credentialId, newPreviousCredentialId);
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountFormServiceTest.java+45 −0 modified@@ -23,6 +23,7 @@ import org.junit.Rule; import org.junit.Test; import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; import org.keycloak.credential.CredentialModel; import org.keycloak.events.Details; import org.keycloak.events.Errors; @@ -33,8 +34,10 @@ import org.keycloak.models.PasswordPolicy; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.models.credential.OTPCredentialModel; import org.keycloak.models.utils.TimeBasedOTP; import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; @@ -63,6 +66,7 @@ import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.RealmBuilder; import org.keycloak.testsuite.util.UIUtils; +import org.keycloak.testsuite.util.URLUtils; import org.keycloak.testsuite.util.UserBuilder; import java.util.Collections; import org.openqa.selenium.By; @@ -992,6 +996,47 @@ public void setupTotp() { assertFalse(errorPage.isCurrent()); } + + @Test + public void removeTotpAsDifferentUser() { + UserResource user1 = ApiUtil.findUserByUsernameId(testRealm(), "user-with-one-configured-otp"); + CredentialRepresentation otpCredential = user1.credentials().stream() + .filter(credentialRep -> OTPCredentialModel.TYPE.equals(credentialRep.getType())) + .findFirst() + .get(); + + // Login as evil user (test-user@localhost) and setup TOTP + totpPage.open(); + loginPage.login("test-user@localhost", "password"); + Assert.assertTrue(totpPage.isCurrent()); + + totpPageSetup(); + + totpPage.configure(totp.generateTOTP(totpPage.getTotpSecret())); + + Assert.assertEquals("Mobile authenticator configured.", profilePage.getSuccess()); + + String currentStateChecker = driver.findElement(By.id("stateChecker")).getAttribute("value"); + + + // Try to delete TOTP of "user-with-one-configured-otp" by replace ID of the TOTP credential in the request + String currentURL = driver.getCurrentUrl(); + + String formParameters = "stateChecker=" + currentStateChecker + + "&submitAction=Delete" + + "&credentialId=" + otpCredential.getId(); + + URLUtils.sendPOSTRequestWithWebDriver(currentURL, formParameters); + + // Assert credential of "user-with-one-configured-otp" was NOT deleted and is still present for the user + Assert.assertTrue(user1.credentials().stream() + .anyMatch(credentialRepresentation -> credentialRepresentation.getType().equals(OTPCredentialModel.TYPE))); + + // Remove TOTP for "test-user" and logout + totpPage.removeTotp(); + totpPage.logout(); + } + @Test public void changeProfileNoAccess() throws Exception { profilePage.open();
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java+35 −0 modified@@ -27,6 +27,7 @@ import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.common.Profile; +import org.keycloak.common.util.ObjectUtil; import org.keycloak.credential.CredentialTypeMetadata; import org.keycloak.events.EventType; import org.keycloak.models.AuthenticationExecutionModel; @@ -394,6 +395,40 @@ public void testCredentialsGet() throws IOException { Assert.assertNull(password.getUserCredentials()); } + + @Test + public void testCRUDCredentialOfDifferentUser() throws IOException { + // Get credential ID of the OTP credential of the different user thant currently logged user + UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "user-with-one-configured-otp"); + CredentialRepresentation otpCredential = user.credentials().stream() + .filter(credentialRep -> OTPCredentialModel.TYPE.equals(credentialRep.getType())) + .findFirst() + .get(); + + // Test that current user can't update the credential, which belongs to the different user + SimpleHttp.Response response = SimpleHttp + .doPut(getAccountUrl("credentials/" + otpCredential.getId() + "/label"), httpClient) + .auth(tokenUtil.getToken()) + .json("new-label") + .asResponse(); + assertEquals(404, response.getStatus()); + + // Test that current user can't delete the credential, which belongs to the different user + response = SimpleHttp + .doDelete(getAccountUrl("credentials/" + otpCredential.getId()), httpClient) + .acceptJson() + .auth(tokenUtil.getToken()) + .asResponse(); + assertEquals(404, response.getStatus()); + + // Assert credential was not updated or removed + CredentialRepresentation otpCredentialLoaded = user.credentials().stream() + .filter(credentialRep -> OTPCredentialModel.TYPE.equals(credentialRep.getType())) + .findFirst() + .get(); + Assert.assertTrue(ObjectUtil.isEqualOrBothNull(otpCredential.getUserLabel(), otpCredentialLoaded.getUserLabel())); + } + // Send REST request to get all credential containers and credentials of current user private List<AccountCredentialResource.CredentialContainer> getCredentials() throws IOException { return SimpleHttp.doGet(getAccountUrl("credentials"), httpClient)
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java+44 −0 modified@@ -32,14 +32,17 @@ import org.keycloak.admin.client.resource.RoleMappingResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.common.VerificationException; import org.keycloak.common.util.Base64; +import org.keycloak.common.util.ObjectUtil; import org.keycloak.credential.CredentialModel; import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; import org.keycloak.models.Constants; import org.keycloak.models.PasswordPolicy; import org.keycloak.models.UserModel; +import org.keycloak.models.credential.OTPCredentialModel; import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.representations.AccessToken; @@ -1958,6 +1961,47 @@ public void testDeleteCredentials() { user.resetPassword(credPasswd); Assert.assertEquals(1, user.credentials().size()); } + + @Test + public void testCRUDCredentialsOfDifferentUser() { + // Get credential ID of the OTP credential of the user1 + UserResource user1 = ApiUtil.findUserByUsernameId(testRealm(), "user-with-one-configured-otp"); + CredentialRepresentation otpCredential = user1.credentials().stream() + .filter(credentialRep -> OTPCredentialModel.TYPE.equals(credentialRep.getType())) + .findFirst() + .get(); + + // Test that when admin operates on user "user2", he can't update, move or remove credentials of different user "user1" + UserResource user2 = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost"); + try { + user2.setCredentialUserLabel(otpCredential.getId(), "new-label"); + Assert.fail("Not expected to successfully update user label"); + } catch (NotFoundException nfe) { + // Expected + } + + try { + user2.moveCredentialToFirst(otpCredential.getId()); + Assert.fail("Not expected to successfully move credential"); + } catch (NotFoundException nfe) { + // Expected + } + + try { + user2.removeCredential(otpCredential.getId()); + Assert.fail("Not expected to successfully remove credential"); + } catch (NotFoundException nfe) { + // Expected + } + + // Assert credential was not removed or updated + CredentialRepresentation otpCredentialLoaded = user1.credentials().stream() + .filter(credentialRep -> OTPCredentialModel.TYPE.equals(credentialRep.getType())) + .findFirst() + .get(); + Assert.assertTrue(ObjectUtil.isEqualOrBothNull(otpCredential.getUserLabel(), otpCredentialLoaded.getUserLabel())); + Assert.assertTrue(ObjectUtil.isEqualOrBothNull(otpCredential.getPriority(), otpCredentialLoaded.getPriority())); + } @Test public void testGetGroupsForUserFullRepresentation() {
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/UserStorageTest.java+55 −0 modified@@ -11,6 +11,7 @@ import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.ObjectUtil; import org.keycloak.credential.CredentialAuthentication; import org.keycloak.credential.CredentialModel; import org.keycloak.credential.UserCredentialStoreManager; @@ -22,6 +23,7 @@ import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.representations.idm.ComponentRepresentation; +import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RoleRepresentation; @@ -76,6 +78,8 @@ import static org.keycloak.storage.UserStorageProviderModel.MAX_LIFESPAN; import static org.keycloak.testsuite.actions.RequiredActionEmailVerificationTest.getPasswordResetEmailLink; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; +import org.keycloak.testsuite.util.WaitUtils; + import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlDoesntStartWith; import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith; @@ -960,6 +964,57 @@ public void testCredentialCRUD(KeycloakSession session) throws Exception { }); } + @Test + public void testCRUDCredentialsOfDifferentUser() { + // Create OTP credential for user1 in the federated storage + testingClient.server().run(session -> { + RealmModel realm = session.realms().getRealmByName("test"); + + UserModel user = session.users().getUserByUsername("thor", realm); + Assert.assertFalse(StorageId.isLocalStorage(user)); + + CredentialModel otp1 = OTPCredentialModel.createFromPolicy(realm, "secret1"); + session.userCredentialManager().createCredential(realm, user, otp1); + }); + + UserResource user1 = ApiUtil.findUserByUsernameId(testRealmResource(), "thor"); + CredentialRepresentation otpCredential = user1.credentials().stream() + .filter(credentialRep -> OTPCredentialModel.TYPE.equals(credentialRep.getType())) + .findFirst() + .get(); + + // Test that when admin operates on user "user2", who is saved in user-storage, he can't update, move or remove credentials of different user "user1" + UserResource user2 = ApiUtil.findUserByUsernameId(testRealmResource(), "tbrady"); + try { + user2.setCredentialUserLabel(otpCredential.getId(), "new-label"); + Assert.fail("Not expected to successfully update user label"); + } catch (NotFoundException nfe) { + // Expected + } + + try { + user2.moveCredentialToFirst(otpCredential.getId()); + Assert.fail("Not expected to successfully move credential"); + } catch (NotFoundException nfe) { + // Expected + } + + try { + user2.removeCredential(otpCredential.getId()); + Assert.fail("Not expected to successfully remove credential"); + } catch (NotFoundException nfe) { + // Expected + } + + // Assert credential was not removed or updated + CredentialRepresentation otpCredentialLoaded = user1.credentials().stream() + .filter(credentialRep -> OTPCredentialModel.TYPE.equals(credentialRep.getType())) + .findFirst() + .get(); + Assert.assertTrue(ObjectUtil.isEqualOrBothNull(otpCredential.getUserLabel(), otpCredentialLoaded.getUserLabel())); + Assert.assertTrue(ObjectUtil.isEqualOrBothNull(otpCredential.getPriority(), otpCredentialLoaded.getPriority())); + } + private void assertOrder(List<CredentialModel> creds, String... expectedIds) { org.keycloak.testsuite.Assert.assertEquals(expectedIds.length, creds.size());
themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties+1 −0 modified@@ -78,6 +78,7 @@ webauthn-help-text=Use your security key to log in. webauthn-passwordless-display-name=Security Key webauthn-passwordless-help-text=Use your security key for passwordless log in. basic-authentication=Basic Authentication +invalidRequestMessage=Invalid Request # Applications page applicationsPageTitle=Applications
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-9695-w6h2-jpv9ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2020-10686ghsaADVISORY
- bugzilla.redhat.com/show_bug.cgighsax_refsource_CONFIRMWEB
- github.com/keycloak/keycloak/commit/5ddd605ee96b8551c7eb00b609a0b97939925b77ghsaWEB
News mentions
0No linked articles in our index yet.