From 232ddac1aa339094e4b3fa25af8cda6f6866b101 Mon Sep 17 00:00:00 2001 From: Marek Posolda Date: Wed, 25 Mar 2026 10:06:09 +0100 Subject: [PATCH] Improve performance of scope processing in TokenManager. Limit for maximum length of OIDC parameters in Token endpoint (#478) closes #47716 Closes CVE-2026-4634 Signed-off-by: mposolda (cherry picked from commit b455ee4f28abb6f2120aff72fd179589cc5267a0) --- .../topics/changes/changes-26_5_7.adoc | 12 ++++++++ .../upgrading/topics/changes/changes.adoc | 4 +++ .../oidc/OIDCLoginProtocolFactory.java | 10 +++---- .../keycloak/protocol/oidc/TokenManager.java | 13 +++++--- .../oidc/endpoints/TokenEndpoint.java | 29 ++++++++++++++++++ .../testsuite/oidc/OIDCScopeTest.java | 30 +++++++++++++++++++ 6 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 docs/documentation/upgrading/topics/changes/changes-26_5_7.adoc diff --git a/docs/documentation/upgrading/topics/changes/changes-26_5_7.adoc b/docs/documentation/upgrading/topics/changes/changes-26_5_7.adoc new file mode 100644 index 000000000000..27768f9dcb7b --- /dev/null +++ b/docs/documentation/upgrading/topics/changes/changes-26_5_7.adoc @@ -0,0 +1,12 @@ +== Notable changes + +Notable changes may include internal behavior changes that prevent common misconfigurations, bugs that are fixed, or changes to simplify running {project_name}. +It also lists significant changes to internal APIs. + +=== Maximum length of the parameters in the OIDC token endpoint + +When the OIDC token endpoint request (or OAuth2 token endpoint request) is sent, a new limit exists for the maximum length of every OIDC/OAuth2 parameter. The maximum length of each parameter is 4,000 characters, +which is aligned with the same limit, which already exists for the parameters sent to OIDC/OAuth authentication request. + +If you want to increase or lower those numbers, start the server with the option `req-params-default-max-size` for the default maximum length of the +OIDC/OAuth2 parameters or you can use something such as `req-params-max-size` for one specific parameter. For more details, see the `login-protocol` provider configuration in the link:{allproviderconfigguide_link}[{allproviderconfigguide_name}]. diff --git a/docs/documentation/upgrading/topics/changes/changes.adoc b/docs/documentation/upgrading/topics/changes/changes.adoc index c06fa05421b1..b3b20ba2d706 100644 --- a/docs/documentation/upgrading/topics/changes/changes.adoc +++ b/docs/documentation/upgrading/topics/changes/changes.adoc @@ -5,6 +5,10 @@ include::changes-26_6_0.adoc[leveloffset=2] +=== Migrating to 26.5.7 + +include::changes-26_5_7.adoc[leveloffset=2] + === Migrating to 26.5.5 include::changes-26_5_5.adoc[leveloffset=2] diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java index e37f8dd5c9d1..e15b2a240973 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java @@ -572,20 +572,20 @@ public List getConfigMetadata() { .property() .name(CONFIG_OIDC_REQ_PARAMS_DEFAULT_MAX_SIZE) .type("int") - .helpText("Maximum default length of the standard OIDC parameter sent to the OIDC authentication request. This applies to most of the standard parameters like for example 'state', 'nonce' etc." + + .helpText("Maximum default length of the standard OIDC parameter sent to the OIDC authentication or token endpoints. This applies to most of the standard parameters like for example 'state', 'nonce' etc." + " The exception is 'login_hint' parameter, which has maximum length of 255 characters.") .defaultValue(DEFAULT_REQ_PARAMS_DEFAULT_MAX_SIZE) .add() .property() .name(CONFIG_OIDC_REQ_PARAMS_MAX_SIZE_PREFIX + "--" + OIDCLoginProtocol.LOGIN_HINT_PARAM) .type("int") - .helpText("Maximum length of the standard OIDC authentication request parameter overriden for the specified parameter. Useful if some standard OIDC parameter should have different limit than '" + CONFIG_OIDC_REQ_PARAMS_DEFAULT_MAX_SIZE + + .helpText("Maximum length of the standard parameter sent to OIDC authentication or token endpoints overriden for the specified parameter. Useful if some standard OIDC parameter should have different limit than '" + CONFIG_OIDC_REQ_PARAMS_DEFAULT_MAX_SIZE + "'. It is needed to add the name of the parameter after this prefix into the configuration. In this example, the '" + OIDCLoginProtocol.LOGIN_HINT_PARAM + "' parameter is used, but this format is supported for any known standard OIDC/OAuth2 parameter.") .add() .property() .name(CONFIG_OIDC_ADD_REQ_PARAMS_MAX_NUMBER) .type("int") - .helpText("Maximum number of additional request parameters sent to the OIDC authentication request. As 'additional request parameter' is meant some custom parameter not directly treated as standard OIDC/OAuth2 protocol parameter. Additional parameters might be useful for example to add custom claims to the OIDC token (in case that also particular protocol mappers are configured).") + .helpText("Maximum number of additional request parameters sent to the OIDC authentication or token endpoints. As 'additional request parameter' is meant some custom parameter not directly treated as standard OIDC/OAuth2 protocol parameter. Additional parameters might be useful for example to add custom claims to the OIDC token (in case that also particular protocol mappers are configured).") .defaultValue(DEFAULT_ADDITIONAL_REQ_PARAMS_MAX_NUMBER) .add() .property() @@ -603,8 +603,8 @@ public List getConfigMetadata() { .property() .name(CONFIG_OIDC_ADD_REQ_PARAMS_FAIL_FAST) .type("boolean") - .helpText("Whether the fail-fast strategy should be enforced in case if the limit for some standard OIDC parameter or additional OIDC parameter is not met for the parameters sent to the OIDC authentication request." + - " If false, then all additional request parameters to not meet the configuration are silently ignored. If true, an exception will be raised and OIDC authentication request will not be allowed.") + .helpText("Whether the fail-fast strategy should be enforced in case if the limit for some standard OIDC parameter or additional OIDC parameter is not met for the parameters sent to the OIDC authentication or token endpoints." + + " If false, then all additional request parameters to not meet the configuration are silently ignored. If true, an exception will be raised and request to the OIDC authentication or token endpoints will not be allowed.") .defaultValue(DEFAULT_ADDITIONAL_REQ_PARAMS_FAIL_FAST) .add() .build(); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 8cfd6820fe79..5f458767f213 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -717,6 +717,7 @@ public static Stream getRequestedClientScopes(KeycloakSession Map allOptionalScopes = client.getClientScopes(false); + OrganizationScope orgScope = tryResolveOrganizationScope(session, scopeParam, user); // Add optional client scopes requested by scope parameter return Stream.concat(parseScopeParameter(scopeParam) .map(name -> { @@ -726,13 +727,13 @@ public static Stream getRequestedClientScopes(KeycloakSession return scope; } - return tryResolveDynamicClientScope(session, scopeParam, user, name); + return tryResolveOrganizationClientScope(session, user, orgScope, name); }) .filter(Objects::nonNull), clientScopes).distinct(); } - private static ClientScopeModel tryResolveDynamicClientScope(KeycloakSession session, String scopeParam, UserModel user, String name) { + private static OrganizationScope tryResolveOrganizationScope(KeycloakSession session, String scopeParam, UserModel user) { if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { OrganizationScope orgScope = OrganizationScope.valueOfScope(session, scopeParam); @@ -744,10 +745,14 @@ private static ClientScopeModel tryResolveDynamicClientScope(KeycloakSession ses return null; } - return orgScope.toClientScope(name, user, session); + return orgScope; + } else { + return null; } + } - return null; + private static ClientScopeModel tryResolveOrganizationClientScope(KeycloakSession session, UserModel user, OrganizationScope orgScope, String name) { + return orgScope == null ? null : orgScope.toClientScope(name, user, session); } /** diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index e60ef72f9352..38149c00680c 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -18,6 +18,8 @@ package org.keycloak.protocol.oidc.endpoints; import java.io.IOException; +import java.util.HashMap; +import java.util.List; import java.util.Map; import javax.xml.namespace.QName; @@ -43,8 +45,10 @@ import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.OIDCProviderConfig; import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.grants.OAuth2GrantType; import org.keycloak.protocol.oidc.token.TokenInterceptorException; @@ -139,6 +143,8 @@ public Response processGrantRequest() { checkParameterDuplicated(); } + checkParameters(); + /* * To request an access token that is bound to a public key using DPoP, the client MUST provide a valid DPoP * proof JWT in a DPoP header when making an access token request to the authorization server's token endpoint. @@ -226,6 +232,29 @@ private void checkParameterDuplicated() { } } + protected void checkParameters() { + OIDCLoginProtocol loginProtocol = (OIDCLoginProtocol) session.getProvider(LoginProtocol.class, OIDCLoginProtocol.LOGIN_PROTOCOL); + OIDCProviderConfig config = loginProtocol.getConfig(); + + Map> paramsCopy = new HashMap<>(formParams); + for (Map.Entry> param : paramsCopy.entrySet()) { + String paramName = param.getKey(); + int totalLengthOfParamValues = param.getValue().stream() + .map(String::length) + .reduce(0, Integer::sum); + int maxLength = config.getMaxLengthForTheParameter(paramName); + if (totalLengthOfParamValues > maxLength) { + logger.warnf("The size of OIDC parameter '%s' is longer (%d) than allowed (%d). %s", paramName, totalLengthOfParamValues, maxLength, config.isAdditionalReqParamsFailFast() ? "Request not allowed." : "Ignoring the parameter."); + if (config.isAdditionalReqParamsFailFast()) { + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "The size of OIDC parameter '" + paramName + "' is longer than allowed.", + Response.Status.BAD_REQUEST); + } else { + formParams.remove(paramName); + } + } + } + } + public static class TokenExchangeSamlProtocol extends SamlProtocol { final SamlClient samlClient; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCScopeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCScopeTest.java index 63389c1ea52d..6ae042d466e9 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCScopeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCScopeTest.java @@ -53,11 +53,13 @@ import org.keycloak.testsuite.util.RoleBuilder; import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.util.TokenUtil; import org.junit.After; import org.junit.Before; import org.junit.Test; +import static org.keycloak.OAuthErrorException.INVALID_SCOPE; import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; import static org.junit.Assert.assertEquals; @@ -654,6 +656,34 @@ public void testClientScopesPermissions() { testApp.removeOptionalClientScope(scopeParentId); } + @Test + public void testLengthyScopeParameter() { + // Scope parameter too long (longer than 4000 characters). Will be ignored + String scope = getLongScopeParameter(1000); + oauth.scope(scope); + AccessTokenResponse response = oauth.doPasswordGrantRequest("john", "password"); + assertEquals(200, response.getStatusCode()); + AccessToken token = oauth.verifyToken(response.getAccessToken()); + Assert.assertFalse(TokenUtil.isOIDCRequest(token.getScope())); + + // Scope parameter relatively long. Should not be ignored + scope = getLongScopeParameter(800); + oauth.scope(scope); + response = oauth.doPasswordGrantRequest("john", "password"); + assertEquals(400, response.getStatusCode()); + assertEquals(INVALID_SCOPE, response.getError()); + } + + // Get very long "scope" parameter created from big list of some unknown scopes + private String getLongScopeParameter(int scopesCount) { + StringBuilder scopeParam = new StringBuilder("openid"); + for (int i = 0 ; i < scopesCount ; i++) { + scopeParam.append(" s").append(i); + } + String scope = scopeParam.toString(); + getLogger().infof("Scopes count: %d, Scope param length: %d", scopesCount, scope.length()); + return scope; + } private void testLoginAndClientScopesPermissions(String username, String expectedRoleScopes, String... expectedRoles) { String userId = ApiUtil.findUserByUsername(testRealm(), username).getId();