8000 Reject group names containing invisible Unicode characters by siddhant-pardeshi · Pull Request #54719 · 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 @@ -57,6 +57,7 @@
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;
import org.keycloak.services.resources.admin.fgap.AdminPermissionManagement;
import org.keycloak.services.resources.admin.fgap.AdminPermissions;
import org.keycloak.services.validation.Validation;
import org.keycloak.utils.GroupUtils;
import org.keycloak.utils.ProfileHelper;

Expand Down Expand Up @@ -144,6 +145,10 @@ public Response updateGroup(GroupRepresentation rep) {
}

if (!Objects.equals(groupName, group.getName())) {
if (Validation.containsInvisibleCharacters(groupName)) {
throw ErrorResponse.error("Group name contains invisible Unicode characters", Response.Status.BAD_REQUEST);
}

boolean exists = siblings().filter(s -> !Objects.equals(s.getId(), group.getId()))
.anyMatch(s -> Objects.equals(s.getName(), groupName));
if (exists) {
Expand Down Expand Up @@ -246,6 +251,13 @@ public Response addChild(GroupRepresentation rep) {
throw ErrorResponse.error("Group name is missing", Response.Status.BAD_REQUEST);
}

// validate the name only when a new subgroup is created; requests that move an
// existing group (rep.id present) do not apply the name, so the stored name
// of legacy groups must not be rejected here
if (rep.getId() == null && Validation.containsInvisibleCharacters(groupName)) {
throw ErrorResponse.error("Group name contains invisible Unicode characters", Response.Status.BAD_REQUEST);
}

try {
Response.ResponseBuilder builder = Response.status(204);
GroupModel child;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import org.keycloak.services.resources.KeycloakOpenAPI;
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;
import org.keycloak.services.resources.admin.fgap.GroupPermissionEvaluator;
import org.keycloak.services.validation.Validation;
import org.keycloak.utils.GroupUtils;
import org.keycloak.utils.SearchQueryUtils;

Expand Down Expand Up @@ -218,6 +219,13 @@ public Response addTopLevelGroup(GroupRepresentation rep) {
throw ErrorResponse.error("Group name is missing", Response.Status.BAD_REQUEST);
}

// validate the name only when a new group is created; requests that move an
// existing group (rep.id present) do not apply the name, so the stored name
// of legacy groups must not be rejected here
if (rep.getId() == null && Validation.containsInvisibleCharacters(groupName)) {
throw ErrorResponse.error("Group name contains invisible Unicode characters", Response.Status.BAD_REQUEST);
}

try {
if (rep.getId() != null) {
child = realm.getGroupById(rep.getId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ public static boolean isUsernameValid(String username) {
return USERNAME_PATTERN.matcher(username).matches();
}

/**
* Check if string contains invisible or formatting-only Unicode characters
* (Unicode general category Cf), e.g. zero-width space (U+200B), word joiner
* (U+2060), soft hyphen (U+00AD), byte order mark (U+FEFF) or bidi controls.
* Such characters are not visible, but they are significant for exact string
* comparisons, which can silently break matching and authorization logic.
*
* @param s to check
* @return true if the string contains at least one invisible character
*/
public static boolean containsInvisibleCharacters(String s) {
if (s == null) {
return false;
}
return s.codePoints().anyMatch(cp -> Character.getType(cp) == Character.FORMAT);
}

public static List<FormMessage> getFormErrorsFromValidation(List<ValidationException.Error> errors) {
List<FormMessage> messages = new ArrayList<>();
for (ValidationException.Error error : errors) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,128 @@ public void updatingGroupWithEmptyNameShouldFail() {
);
}

// Issue #54532: Group names with invisible Unicode characters are accepted without validation
@Test
public void createGroupWithInvisibleUnicodeNameShouldFail() {
GroupRepresentation group = new GroupRepresentation();

group.setName("UR_EXAMPLE\u2060");
try (Response response = managedRealm.admin().groups().add(group)) {
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
}

group.setName("zero\u200Bwidth");
try (Response response = managedRealm.admin().groups().add(group)) {
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
}

group.setName("soft\u00ADhyphen");
try (Response response = managedRealm.admin().groups().add(group)) {
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
}
}

// Issue #54532: Renaming a group to a name containing invisible Unicode characters is rejected
@Test
public void renamingGroupToInvisibleUnicodeNameShouldFail() {
RealmResource realm = managedRealm.admin();

GroupRepresentation group = new GroupRepresentation();
group.setName("cleanGroupName");

Response response = realm.groups().add(group);
String groupId = ApiUtil.getCreatedId(response);
managedRealm.cleanup().add(r -> r.groups().group(groupId).remove());

group.setName("cleanGroupName\u200B");
assertThrows(BadRequestException.class,
() -> realm.groups().group(groupId).update(group),
"Renaming a group to a name with invisible Unicode characters should fail"
);
}

// Issue #54532: The subgroup creation path validates names as well
@Test
public void createSubGroupWithInvisibleUnicodeNameShouldFail() {
RealmResource realm = managedRealm.admin();

GroupRepresentation parent = new GroupRepresentation();
parent.setName("parentGroup");
String parentId = ApiUtil.getCreatedId(realm.groups().add(parent));
managedRealm.cleanup().add(r -> r.groups().group(parentId).remove());

GroupRepresentation child = new GroupRepresentation();
child.setName("childGroup\u200B");
try (Response response = realm.groups().group(parentId).subGroup(child)) {
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
}
}

// Issue #54532: Names containing only visible characters, however unusual, remain valid
@Test
public void createGroupWithVisibleSpecialCharactersNameSucceeds() {
GroupRepresentation group = new GroupRepresentation();
group.setName("Gruppe M\u00FCnchen \u6771\u4EAC \uD83D\uDE80");

try (Response response = managedRealm.admin().groups().add(group)) {
assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
String groupId = ApiUtil.getCreatedId(response);
managedRealm.cleanup().add(r -> r.groups().group(groupId).remove());
assertEquals("Gruppe M\u00FCnchen \u6771\u4EAC \uD83D\uDE80",
managedRealm.admin().groups().group(groupId).toRepresentation().getName());
}
}

// Issue #54532: Legacy groups whose names already contain invisible characters can still be updated without renaming
@Test
public void updatingLegacyGroupWithInvisibleCharactersInNameSucceeds() {
String realmName = managedRealm.getName();
final String groupId = runOnServer.fetch(session -> {
RealmModel realm = session.realms().getRealmByName(realmName);
GroupModel legacyGroup = realm.createGroup("legacy\u200BGroup");
return legacyGroup.getId();
}, String.class);
managedRealm.cleanup().add(r -> r.groups().group(groupId).remove());

GroupRepresentation rep = new GroupRepresentation();
rep.setName("legacy\u200BGroup");
managedRealm.admin().groups().group(groupId).update(rep);

assertEquals("legacy\u200BGroup", managedRealm.admin().groups().group(groupId).toRepresentation().getName());
}

// Review feedback on this PR: moving a legacy group whose unchanged name contains
// invisible characters must not be blocked (the move endpoints do not apply the name)
@Test
public void moveLegacyGroupWithInvisibleCharactersInNameSucceeds() {
String realmName = managedRealm.getName();
final String groupId = runOnServer.fetch(session -> {
RealmModel realm = session.realms().getRealmByName(realmName);
GroupModel legacyGroup = realm.createGroup("legacy\u2060Group");
return legacyGroup.getId();
}, String.class);
managedRealm.cleanup().add(r -> r.groups().group(groupId).remove());

GroupRepresentation parent = new GroupRepresentation();
parent.setName("moveParentGroup");
String parentId = ApiUtil.getCreatedId(managedRealm.admin().groups().add(parent));
managedRealm.cleanup().add(r -> r.groups().group(parentId).remove());

GroupRepresentation rep = new GroupRepresentation();
rep.setId(groupId);
rep.setName("legacy\u2060Group");

// top-level move endpoint: unchanged legacy name must not be validated
try (Response response = managedRealm.admin().groups().add(rep)) {
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
}

// subgroup move endpoint: same guarantee
try (Response response = managedRealm.admin().groups().group(parentId).subGroup(rep)) {
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
}
}

@Test
public void createAndTestGroups() throws IOException {
RoleRepresentation topRole = createRealmRole(managedRealm, RoleBuilder.create().name("topRole").build());
Expand Down
0