From 5f3540f7061ca5e32e60b086ecb57df3fca8338f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Barto=C5=A1?= Date: Thu, 30 Jul 2026 09:58:06 +0200 Subject: [PATCH] [CVE-2026-17048] Keycloak Admin REST API Leaks Vault-Resolved Rotated Client Secrets (#51209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #51145 Signed-off-by: Martin Bartoš (cherry picked from commit 4e1c2ad52df59c74a6a4a9e4a3758a7ec29287ab) --- .../oidc/OIDCClientSecretConfigWrapper.java | 29 ++++++++++++-- .../keycloak/tests/admin/ClientVaultTest.java | 40 ++++++++++++++++++- .../tests/admin/vault/default_rotated__secret | 1 + 3 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 tests/base/src/test/resources/org/keycloak/tests/admin/vault/default_rotated__secret diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCClientSecretConfigWrapper.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCClientSecretConfigWrapper.java index 54f71dc2bf97..226ff9998814 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCClientSecretConfigWrapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCClientSecretConfigWrapper.java @@ -104,9 +104,32 @@ public boolean hasRotatedSecret() { return StringUtil.isNotBlank(getAttribute(CLIENT_ROTATED_SECRET)) && StringUtil.isNotBlank(getAttribute(CLIENT_ROTATED_SECRET_CREATION_TIME)); } + /** + * Returns the rotated client secret value without vault resolution. + * Vault expressions should not be resolved when returning secrets through the Admin API + * to avoid leaking sensitive vault-backed values. + * Use {@link #hasRotatedSecret()} to check whether a rotated secret is effectively present before calling this method. + */ public String getClientRotatedSecret(KeycloakSession session) { + return getClientRotatedSecret(session, false); + } + + /** + * Returns the rotated client secret value, optionally resolving vault expressions. + * Vault resolution should only be enabled for authentication validation, never for + * returning values through the Admin API to avoid leaking sensitive vault-backed values. + * Use {@link #hasRotatedSecret()} to check whether a rotated secret is effectively present before calling this method. + * + * @param session the keycloak session + * @param resolveVault if {@code true}, vault expressions like {@code ${vault.key}} are resolved to their actual values; + * if {@code false}, the raw stored value (potentially a vault placeholder) is returned + */ + public String getClientRotatedSecret(KeycloakSession session, boolean resolveVault) { String secret = getAttribute(CLIENT_ROTATED_SECRET); - return session == null ? getAttribute(CLIENT_ROTATED_SECRET) : session.vault().getStringSecret(secret).get().orElse(secret); + if (resolveVault && session != null) { + return session.vault().getStringSecret(secret).get().orElse(secret); + } + return secret; } public void setClientRotatedSecret(String secret) { @@ -219,7 +242,7 @@ public boolean validateRotatedSecret(KeycloakSession session, String secret) { return false; } - return MessageDigest.isEqual(secret.getBytes(), getClientRotatedSecret(session).getBytes()); + return MessageDigest.isEqual(secret.getBytes(), getClientRotatedSecret(session, true).getBytes()); } @@ -264,7 +287,7 @@ private ReadOnlyRotatedSecretClientModel(KeycloakSession session, ClientModel cl @Override public String getSecret() { - return OIDCClientSecretConfigWrapper.this.getClientRotatedSecret(session); + return OIDCClientSecretConfigWrapper.this.getClientRotatedSecret(session, true); } } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/ClientVaultTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/ClientVaultTest.java index 8994a013ed3a..6b4e4f6632fb 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/ClientVaultTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/ClientVaultTest.java @@ -19,8 +19,11 @@ import java.net.URL; +import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.authentication.authenticators.client.JWTClientSecretAuthenticator; +import org.keycloak.common.Profile; import org.keycloak.crypto.Algorithm; +import org.keycloak.models.ClientSecretConstants; import org.keycloak.protocol.oidc.client.authentication.JWTClientSecretCredentialsProvider; import org.keycloak.testframework.annotations.InjectRealm; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; @@ -33,6 +36,7 @@ import org.keycloak.testframework.realm.UserBuilder; import org.keycloak.testframework.server.KeycloakServerConfig; import org.keycloak.testframework.server.KeycloakServerConfigBuilder; +import org.keycloak.tests.utils.admin.AdminApiUtil; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.junit.jupiter.api.Test; @@ -76,6 +80,31 @@ void testClientVaultWithInvalidVaultReference() { assertEquals(401, response.getStatusCode()); } + @Test + void testRotatedSecretEndpointDoesNotResolveVault() { + String vaultExpression = "${vault.rotated_secret}"; + ClientResource clientResource = AdminApiUtil.findClientByClientId(managedRealm.admin(), "myclient-with-rotated-vault-secret"); + String rotatedSecret = clientResource.getClientRotatedSecret().getValue(); + assertEquals(vaultExpression, rotatedSecret); + } + + @Test + void testAuthenticationWithVaultBackedRotatedSecret() { + // "rotatedsecret" is the resolved value from the vault file default_rotated__secret + AccessTokenResponse response = oauthClient + .client("myclient-with-rotated-vault-secret", "rotatedsecret") + .doPasswordGrantRequest("test-user@localhost", "password"); + + assertEquals(200, response.getStatusCode()); + assertNotNull(response.getAccessToken()); + + AccessTokenResponse failResponse = oauthClient + .client("myclient-with-rotated-vault-secret", "wrong-secret") + .doPasswordGrantRequest("test-user@localhost", "password"); + + assertEquals(401, failResponse.getStatusCode()); + } + @Test void testClientVaultWithJwtClientSecretAuthenticator() { String clientId = "myclient-jwt-client-secret-authenticator"; @@ -100,7 +129,8 @@ public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) if (url == null) { throw new RuntimeException("Unable to find the vault folder in the classpath for the default_client__secret file!"); } - return config.option("vault", "file").option("vault-dir", url.getPath()); + return config.option("vault", "file").option("vault-dir", url.getPath()) + .features(Profile.Feature.CLIENT_SECRET_ROTATION); } } @@ -117,6 +147,14 @@ public RealmBuilder configure(RealmBuilder realm) { .directAccessGrantsEnabled(true) .secret("${vault.non_existing_client_secret}")); + realm.clients(ClientBuilder.create("myclient-with-rotated-vault-secret") + .publicClient(false) + .directAccessGrantsEnabled(true) + .secret("some-primary-secret") + .attribute(ClientSecretConstants.CLIENT_ROTATED_SECRET, "${vault.rotated_secret}") + .attribute(ClientSecretConstants.CLIENT_ROTATED_SECRET_CREATION_TIME, String.valueOf(System.currentTimeMillis() / 1000)) + .attribute(ClientSecretConstants.CLIENT_ROTATED_SECRET_EXPIRATION_TIME, String.valueOf(System.currentTimeMillis() / 1000 + 86400))); + realm.clients(ClientBuilder.create("myclient-jwt-client-secret-authenticator") .publicClient(false) .directAccessGrantsEnabled(true) diff --git a/tests/base/src/test/resources/org/keycloak/tests/admin/vault/default_rotated__secret b/tests/base/src/test/resources/org/keycloak/tests/admin/vault/default_rotated__secret new file mode 100644 index 000000000000..1f912d78d8a1 --- /dev/null +++ b/tests/base/src/test/resources/org/keycloak/tests/admin/vault/default_rotated__secret @@ -0,0 +1 @@ +rotatedsecret \ No newline at end of file