From bb7a5f5c4df532f7ca835a2eda5b37be408adb07 Mon Sep 17 00:00:00 2001 From: sergioperezcheco Date: Mon, 27 Jul 2026 08:21:21 +0800 Subject: [PATCH] Fix NPE in SCIM PATCH when request body contains "schemas": null ScimResourceTypeResource.patch() calls request.getSchemas().contains() without a null check. When a client sends a PATCH request body with "schemas": null, Jackson overwrites the default Set.of(SCHEMA) initializer in PatchRequest with null, and the .contains() call throws a NullPointerException resulting in HTTP 500 instead of a proper 400 Bad Request. Add a null check so a missing schemas set is treated the same as a schemas set without the PATCH op schema, returning invalidSyntax (400). Adds a test that sends a PATCH with a null schemas set and asserts the 400 response. Fixes #51156 Signed-off-by: sergioperezcheco --- .../services/ScimResourceTypeResource.java | 4 +- .../org/keycloak/tests/scim/tck/UserTest.java | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/scim/services/src/main/java/org/keycloak/scim/services/ScimResourceTypeResource.java b/scim/services/src/main/java/org/keycloak/scim/services/ScimResourceTypeResource.java index 1a68a2037625..f7480896f44a 100644 --- a/scim/services/src/main/java/org/keycloak/scim/services/ScimResourceTypeResource.java +++ b/scim/services/src/main/java/org/keycloak/scim/services/ScimResourceTypeResource.java @@ -3,6 +3,7 @@ import java.io.InputStream; import java.time.Instant; import java.util.List; +import java.util.Set; import java.util.function.BiFunction; import java.util.stream.Stream; @@ -223,7 +224,8 @@ public Response patch(@PathParam("id") String id, PatchRequest request) { return resourceNotFound(id); } - if (!request.getSchemas().contains(Scim.PATCH_OP_CORE_SCHEMA)) { + Set schemas = request.getSchemas(); + if (schemas == null || !schemas.contains(Scim.PATCH_OP_CORE_SCHEMA)) { return invalidSyntax("No PATCH op schema provided in request"); } diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java index cddbec8fce59..6a61617fa0fe 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java @@ -21,6 +21,8 @@ import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.userprofile.config.UPAttribute; import org.keycloak.representations.userprofile.config.UPAttributePermissions; +import org.keycloak.OAuth2Constants; +import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.userprofile.config.UPAttributeRequired; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.scim.client.ResourceFilter; @@ -36,6 +38,7 @@ import org.keycloak.scim.resource.user.GroupMembership; import org.keycloak.scim.resource.user.Name; import org.keycloak.scim.resource.user.User; +import org.keycloak.testframework.annotations.InjectHttpClient; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.events.AdminEventAssertion; import org.keycloak.testframework.realm.ClientBuilder; @@ -45,6 +48,11 @@ import org.keycloak.testframework.util.ApiUtil; import org.keycloak.userprofile.config.UPConfigUtils; +import org.apache.http.HttpHeaders; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpPatch; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; import com.fasterxml.jackson.annotation.JsonProperty; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; @@ -76,6 +84,9 @@ public class UserTest extends AbstractScimTest { @InjectScimClient(clientId = "noaccess-scim-client", clientSecret = "secret", attachTo = "noaccess-scim-client") ScimClient noAccessClient; + @InjectHttpClient + HttpClient httpClient; + @BeforeEach public void onBefore() { UPConfig upConfig = realm.admin().users().userProfile().getConfiguration(); @@ -605,6 +616,38 @@ public void testPatchAdd() { assertEquals("Amanda", actual.getFirstName()); } + @Test + public void testPatchWithNullSchemas() throws Exception { + User expected = client.users().create(createUser()); + + // PatchRequest carries @JsonInclude(NON_NULL), so setSchemas(null) is silently dropped + // during serialization and the server would retain its default PATCH schema. To actually + // exercise the null-schemas path we send a raw PATCH body with an explicit "schemas": null. + String tokenUrl = keycloakUrls.getToken(realm.getName()); + String tokenBody = OAuth2Constants.GRANT_TYPE + "=" + OAuth2Constants.CLIENT_CREDENTIALS + + "&" + OAuth2Constants.CLIENT_ID + "=scim-client" + + "&" + OAuth2Constants.CLIENT_SECRET + "=secret"; + org.apache.http.client.methods.HttpPost tokenRequest = new org.apache.http.client.methods.HttpPost(tokenUrl); + tokenRequest.setHeader(HttpHeaders.CONTENT_TYPE, org.apache.http.entity.ContentType.APPLICATION_FORM_URLENCODED.getMimeType()); + tokenRequest.setEntity(new StringEntity(tokenBody, org.apache.http.entity.ContentType.APPLICATION_FORM_URLENCODED)); + + String accessToken; + org.apache.http.HttpResponse tokenResponse = httpClient.execute(tokenRequest); + AccessTokenResponse atr = org.keycloak.util.JsonSerialization.readValue(tokenResponse.getEntity().getContent(), AccessTokenResponse.class); + accessToken = atr.getToken(); + + String url = keycloakUrls.getBase() + "/realms/" + realm.getName() + "/scim/v2/Users/" + expected.getId(); + HttpPatch patch = new HttpPatch(url); + patch.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); + patch.setHeader(HttpHeaders.CONTENT_TYPE, "application/scim+json"); + patch.setEntity(new StringEntity( + "{\"schemas\": null, \"Operations\": [{\"op\": \"replace\", \"path\": \"active\", \"value\": false}]}", + ContentType.create("application/scim+json"))); + + org.apache.http.HttpResponse response = httpClient.execute(patch); + assertEquals(400, response.getStatusLine().getStatusCode()); + } + @Test public void testPatchReplace() { User expected = client.users().create(createUser());