8000 Escape delimiter in federated and composite cache keys to prevent col… by sguilhen · Pull Request #54792 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,15 @@ private static String getUserByFederatedIdentityCacheKey(String realmId, Federat
return getUserByFederatedIdentityCacheKey(realmId, socialLink.getIdentityProvider(), socialLink.getUserId());
}

private static String escapeCacheKeyComponent(String value) {
if (value == null) {
return "";
}
return value.replace("\\", "\\\\").replace(".", "\\.");
}

static String getUserByFederatedIdentityCacheKey(String realmId, String identityProvider, String socialUserId) {
return realmId + ".idp." + identityProvider + "." + socialUserId;
return realmId + ".idp." + escapeCacheKeyComponent(identityProvider) + "." + escapeCacheKeyComponent(socialUserId);
}

static String getFederatedIdentityLinksCacheKey(String userId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,15 @@ public static String cacheKeyIdpAlias(RealmModel realm, String alias) {
return realm.getId() + "." + alias + IDP_ALIAS_KEY_SUFFIX;
}

private static String escapeCacheKeyComponent(String value) {
if (value == null) {
return "";
}
return value.replace("\\", "\\\\").replace(".", "\\.");
}

private static String cacheKeyIdpMapperAliasName(RealmModel realm, String alias, String name) {
return realm.getId() + "." + alias + IDP_ALIAS_KEY_SUFFIX + "." + name;
return realm.getId() + "." + escapeCacheKeyComponent(alias) + IDP_ALIAS_KEY_SUFFIX + "." + escapeCacheKeyComponent(name);
}

public static String cacheKeyOrgId(RealmModel realm, String orgId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
*/
package org.keycloak.authentication.actiontoken.idpverifyemail;

import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
Expand All @@ -28,11 +31,13 @@
import org.keycloak.authentication.actiontoken.ActionTokenContext;
import org.keycloak.authentication.actiontoken.TokenUtils;
import org.keycloak.authentication.authenticators.broker.IdpEmailVerificationAuthenticator;
import org.keycloak.common.util.Base64Url;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.FederatedIdentityModel;
Expand Down Expand Up @@ -210,8 +215,11 @@ public static boolean runIfUserVerified(KeycloakSession session, UserModel user,
return isUserVerified;
}

static String getUserVerifiedSingleObjectKey(String userId, String idpAlias, String externalId) {
return "kc.brokering.user.verified." + userId + "." + idpAlias + "." + externalId;
public static String getUserVerifiedSingleObjectKey(String userId, String idpAlias, String externalId) {
String binding = Stream.of(userId, idpAlias, externalId)
.map(value -> Base64Url.encode(value.getBytes(StandardCharsets.UTF_8)))
.collect(Collectors.joining("."));
return "kc.brokering.user.verified." + HashUtils.sha256UrlEncodedHash(binding, StandardCharsets.UTF_8);
}

private Response sendLinkConfirmedAlready(KeycloakSession session, EventBuilder event, UserModel user, IdpVerifyAccountLinkActionToken token) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderMapperSyncMode;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
import org.keycloak.representations.idm.IdentityProviderMapperTypeRepresentation;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.events.AdminEventAssertion;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.keycloak.testframework.util.ApiUtil;
Expand All @@ -44,6 +47,9 @@ public class IdentityProviderMapperTest extends AbstractIdentityProviderTest {
@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm managedRealm;

@InjectRunOnServer
RunOnServerClient runOnServer;

@Test
public void testMapperTypes() {

Expand Down Expand Up @@ -334,6 +340,58 @@ public void testUpdateMapperByIdRejectsForeignIdentityProvider() {
assertEquals("offline_access", providerB.getMapperById(idB).getConfig().get("role"));
}

@Test
public void testIdpMapperCacheDelimiterCollision() {
String realmName = managedRealm.getName();

// Tuple A: alias="corp.idp.alias.part", name="mapper"
// Tuple B: alias="corp", name="part.idp.alias.mapper"
// Unescaped format: realmId + "." + alias + ".idp.alias." + name
// Without escaping, both tuples yield: realmId + ".corp.idp.alias.part.idp.alias.mapper"
create(createRep("corp.idp.alias.part", "google"));
create(createRep("corp", "google"));

IdentityProviderResource providerA = managedRealm.admin().identityProviders().get("corp.idp.alias.part");
IdentityProviderResource providerB = managedRealm.admin().identityProviders().get("corp");

addHardcodedRoleMapper(providerA, "corp.idp.alias.part", "mapper", "offline_access");

// Prime cache with Tuple A via getMapperByName in its own transaction
runOnServer.run(session -> {
RealmModel realm = session.realms().getRealmByName(realmName);
session.getContext().setRealm(realm);

IdentityProviderMapperModel mapper = session.identityProviders().getMapperByName("corp.idp.alias.part", "mapper");
Assertions.assertNotNull(mapper);
assertEquals("offline_access", mapper.getConfig().get("role"));
});

// Lookup Tuple B via getMapperByName must NOT return Tuple A from cache
runOnServer.run(session -> {
RealmModel realm = session.realms().getRealmByName(realmName);
session.getContext().setRealm(realm);

IdentityProviderMapperModel mapper = session.identityProviders().getMapperByName("corp", "part.idp.alias.mapper");
Assertions.assertNull(mapper, "Lookup for (corp, part.idp.alias.mapper) should not collide with (corp.idp.alias.part, mapper)");
});

// Now create Tuple B and verify both resolve independently from cache
addHardcodedRoleMapper(providerB, "corp", "part.idp.alias.mapper", "master-realm.manage-realm");

runOnServer.run(session -> {
RealmModel realm = session.realms().getRealmByName(realmName);
session.getContext().setRealm(realm);

IdentityProviderMapperModel mapperA = session.identityProviders().getMapperByName("corp.idp.alias.part", "mapper");
IdentityProviderMapperModel mapperB = session.identityProviders().getMapperByName("corp", "part.idp.alias.mapper");

Assertions.assertNotNull(mapperA);
Assertions.assertNotNull(mapperB);
assertEquals("offline_access", mapperA.getConfig().get("role"));
assertEquals("master-realm.manage-realm", mapperB.getConfig().get("role"));
});
}

private String addHardcodedRoleMapper(IdentityProviderResource provider, String alias, String name, String role) {
IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation();
mapper.setIdentityProviderAlias(alias);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,57 @@ public void reLoginAfterIdpDeletedAndRecreatedWithSameAlias() {
});
}

@Test
public void testFederatedIdentityCacheDelimiterCollision() {
String realmName = managedRealm.getName();

createIdentityProvider("corp.saml");
createIdentityProvider("corp");

UserRepresentation user1 = new UserRepresentation();
user1.setUsername("alice");
String user1Id;
try (Response response = managedRealm.admin().users().create(user1)) {
user1Id = ApiUtil.getCreatedId(response);
}

FederatedIdentityRepresentation link1 = new FederatedIdentityRepresentation();
link1.setIdentityProvider("corp.saml");
link1.setUserId("alice");
link1.setUserName("alice");
try (Response response = managedRealm.admin().users().get(user1Id).addFederatedIdentity("corp.saml", link1)) {
assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus());
}

// Prime cache with (corp.saml, alice)
runOnServer.run(session -> {
RealmModel realm = session.realms().getRealmByName(realmName);
session.getContext().setRealm(realm);

FederatedIdentityModel socialLink = new FederatedIdentityModel("corp.saml", "alice", "alice");
UserModel found = session.users().getUserByFederatedIdentity(realm, socialLink);
assertNotNull(found);
assertEquals("alice", found.getUsername());
});

// Lookup (corp, saml.alice) must NOT return alice due to delimiter collision
runOnServer.run(session -> {
RealmModel realm = session.realms().getRealmByName(realmName);
session.getContext().setRealm(realm);

FederatedIdentityModel socialLink = new FederatedIdentityModel("corp", "saml.alice", "saml.alice");
UserModel found = session.users().getUserByFederatedIdentity(realm, socialLink);
assertNull(found, "Lookup for (corp, saml.alice) should not collide with (corp.saml, alice)");
});
}

private void createIdentityProvider() {
createIdentityProvider(IDP_ALIAS);
}

private void createIdentityProvider(String alias) {
IdentityProviderRepresentation idp = new IdentityProviderRepresentation();
idp.setAlias(IDP_ALIAS);
idp.setAlias(alias);
idp.setProviderId("oidc");
idp.setEnabled(true);
managedRealm.admin().identityProviders().create(idp).close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,23 @@ private void waitForFederatedIdentity(UserResource userResource, int expectedCou

private String linkProofKey(String consumerUserId) {
String federatedUserId = getProviderRealm().admin().users().search(getUserLogin(), true).get(0).getId();
return "kc.brokering.user.verified." + consumerUserId + "." + getIdpAlias() + "." + federatedUserId;
return IdpVerifyAccountLinkActionTokenHandler.getUserVerifiedSingleObjectKey(consumerUserId, getIdpAlias(), federatedUserId);
}

@Test
public void testLinkProofKeyDelimiterCollisionCrossConsumptionFails() {
String consumerUserId = "victim-user-id";
String key1 = IdpVerifyAccountLinkActionTokenHandler.getUserVerifiedSingleObjectKey(consumerUserId, "corp", "partner.user");
String key2 = IdpVerifyAccountLinkActionTokenHandler.getUserVerifiedSingleObjectKey(consumerUserId, "corp.partner", "user");
org.junit.jupiter.api.Assertions.assertNotEquals(key1, key2);

runOnServer.run(session -> session.singleUseObjects().put(key1, 300, java.util.Map.of()));
try {
assertTrue(runOnServer.fetch(session -> session.singleUseObjects().contains(key1), Boolean.class));
assertFalse(runOnServer.fetch(session -> session.singleUseObjects().contains(key2), Boolean.class));
} finally {
runOnServer.run(session -> session.singleUseObjects().remove(key1));
}
}

private boolean isLinkProofPresent(String consumerUserId) {
Expand Down
Loading
0