8000 Validate group names for control characters by Inf1mum · Pull Request #54828 · 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 8000 @@ -144,6 +145,10 @@ public Response updateGroup(GroupRepresentation rep) {
}

if (!Objects.equals(groupName, group.getName())) {
if (Validation.containsControlCharacters(groupName)) {
throw ErrorResponse.error("Group name contains control 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.containsControlCharacters(groupName)) {
throw ErrorResponse.error("Group name contains control characters", Response.Status.BAD_REQUEST);
}

try {
Response.ResponseBuilder builder = Response.status(204);
GroupModel child;
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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.containsControlCharacters(groupName)) {
throw ErrorResponse.error("Group name contains control 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 @@ -61,6 +61,23 @@ public static boolean isBlank(String s) {
return s == null || s.trim().length() == 0;
}

/**
* Check if string contains control characters (Unicode general category Cc),
* e.g. NUL (U+0000), DEL (U+007F) or the C1 controls (U+0080-U+009F). Such
* characters are not printable, can break storage engines (for example,
* PostgreSQL does not allow NUL bytes in text values) and 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 control character
*/
public static boolean containsControlCharacters(String s) {
if (s == null) {
return false;
}
return s.codePoints().anyMatch(cp -> Character.getType(cp) == Character.CONTROL);
}

public static boolean isEmailValid(String email) {
return EmailValidationUtil.isValidEmail(email);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,127 @@ public void updatingGroupWithEmptyNameShouldFail() {
);
}

// Issue #54721: Group names with control characters (e.g. NUL) are accepted without validation
@Test
public void createGroupWithControlCharacterNameShouldFail() {
GroupRepresentation group = new GroupRepresentation();

group.setName("a\u0000b");
try (Response response = managedRealm.admin().groups().add(group)) {
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
ErrorRepresentation error = response.readEntity(ErrorRepresentation.class);
assertEquals("Group name contains control characters", error.getErrorMessage());
}

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

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

// Issue #54721: Renaming a group to a name containing control characters is rejected
@Test
public void renamingGroupToControlCharacterNameShouldFail() {
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\u0000");
assertThrows(BadRequestException.class,
() -> realm.groups().group(groupId).update(group),
"Renaming a group to a name with control characters should fail"
);
}

// Issue #54721: The subgroup creation path validates names as well
@Test
public void createSubGroupWithControlCharacterNameShouldFail() {
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\u0000");
try (Response response = realm.groups().group(parentId).subGroup(child)) {
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
}
}

// Issue #54721: Visible special characters (accents, CJK, emoji) remain valid
@Test
public void createGroupWithVisibleSpecialCharactersNameSucceeds() {
GroupRepresentation group = new GroupRepresentation();
group.setName("Gruppe M\u00FCnchen \u6771\u4EAC \uD83D\uDE80");

String groupId = ApiUtil.getCreatedId(managedRealm.admin().groups().add(group));
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 #54721: Legacy groups whose names already contain control characters can still be updated without renaming
@Test
public void updatingLegacyGroupWithControlCharactersInNameSucceeds() {
String realmName = managedRealm.getName();
final String groupId = runOnServer.fetch(session -> {
RealmModel realm = session.realms().getRealmByName(realmName);
GroupModel legacyGroup = realm.createGroup("legacy\u0000Group");
return legacyGroup.getId();
}, String.class);
managedRealm.cleanup().add(r -> r.groups().group(groupId).remove());

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

assertEquals("legacy\u0000Group", managedRealm.admin().groups().group(groupId).toRepresentation().getName());
Comment on lines +464 to +473
}

// Issue #54721: moving a legacy group whose unchanged name contains control
// characters must not be blocked (the move endpoints do not apply the name)
@Test
public void moveLegacyGroupWithControlCharactersInNameSucceeds() {
String realmName = managedRealm.getName();
final String groupId = runOnServer.fetch(session -> {
RealmModel realm = session.realms().getRealmByName(realmName);
GroupModel legacyGroup = realm.createGroup("legacy\u0000Group");
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\u0000Group");

// 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
Loading
0