Limit the inflating size for the SAML redirect binding

Closes #46372

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
Ricardo Martin 2026-02-17 19:40:11 +01:00 committed by GitHub
parent 4253a79eb2
commit 4f90ef67f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 230 additions and 55 deletions

View file

@ -76,6 +76,7 @@ import org.keycloak.saml.common.exceptions.ConfigurationException;
import org.keycloak.saml.common.exceptions.ProcessingException;
import org.keycloak.saml.common.util.DocumentUtil;
import org.keycloak.saml.processing.api.saml.v2.sig.SAML2Signature;
import org.keycloak.saml.processing.api.util.DeflateUtil;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.saml.processing.core.saml.v2.util.AssertionUtil;
import org.keycloak.saml.processing.core.util.KeycloakKeySamlExtensionGenerator;
@ -100,6 +101,8 @@ import static org.keycloak.adapters.saml.SamlPrincipal.DEFAULT_ROLE_ATTRIBUTE_NA
*/
public abstract class AbstractSamlAuthenticationHandler implements SamlAuthenticationHandler {
public static final String MAX_INFLAFING_SIZE_PROP = "org.keycloak.adapters.saml.maxInflatingSize";
private static final long MAX_INFLAFING_SIZE = Long.getLong(MAX_INFLAFING_SIZE_PROP, DeflateUtil.DEFAULT_MAX_INFLATING_SIZE);
protected static Logger log = Logger.getLogger(WebBrowserSsoAuthenticationHandler.class);
protected final HttpFacade facade;
@ -177,7 +180,7 @@ public abstract class AbstractSamlAuthenticationHandler implements SamlAuthentic
if (index > -1) {
requestUri = requestUri.substring(0, index);
}
holder = SAMLRequestParser.parseRequestRedirectBinding(samlRequest);
holder = SAMLRequestParser.parseRequestRedirectBinding(samlRequest, MAX_INFLAFING_SIZE);
} else {
postBinding = true;
holder = SAMLRequestParser.parseRequestPostBinding(samlRequest);
@ -634,7 +637,7 @@ public abstract class AbstractSamlAuthenticationHandler implements SamlAuthentic
}
protected SAMLDocumentHolder extractRedirectBindingResponse(String response) {
return SAMLRequestParser.parseRequestRedirectBinding(response);
return SAMLRequestParser.parseRequestRedirectBinding(response, MAX_INFLAFING_SIZE);
}

View file

@ -99,6 +99,7 @@
:upgradingguide_link: {project_doc_base_url}/upgrading/
:upgradingguide_link_latest: {project_doc_base_url_latest}/upgrading/
:upgradingclientlibs_link: https://www.keycloak.org/securing-apps/upgrading
:saml_galleon_layers_link: https://www.keycloak.org/securing-apps/saml-galleon-layers
:upgradingclientlibs_name: Upgrading {project_name} Client libraries
:releasenotes_name: Release Notes
:releasenotes_name_short: {releasenotes_name}

View file

@ -21,3 +21,15 @@ It also lists significant changes to internal APIs.
In version 26.4.0, the `server-info` endpoint changed to just return the system information for administrators in the admin realm. Nevertheless, the version property was detected to be needed by some products that interact with {project_name}. Now that property is included for administrators in the realm with permission `manage-realm`.
The workaround of the `view-system` permission is more restricted too. It can only be assigned by administrators in the master realm using link:{adminguide_link}#_fine_grained_permissions[FGAP]. This permission will be deleted in a future version.
=== Maximum inflating size for the SAML redirect binding
Since this release, the {project_name} SAML implementation limits the data that can be inflated through the `REDIRECT` binding. The default maximum size is 128KB, the decompression stops when that value is exceeded and returns an error. The option `spi-login-protocol--saml--max-inflating-size` can be used to increase the default limit.
.Increasing limit to 512KB
[source,bash]
----
bin/kc.[sh|bat] --spi-login-protocol--saml--max-inflating-size=524288
----
The same restriction is applied for the link:{saml_galleon_layers_link}[{project_name} SAML Galleon feature pack]. Although, in this case, you need to add a system property to the Wildfly/EAP server to change the default maximum size: `-Dorg.keycloak.adapters.saml.maxInflatingSize=524288`.

View file

@ -18,7 +18,6 @@
package org.keycloak.saml;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import org.keycloak.common.util.StreamUtil;
@ -27,6 +26,7 @@ import org.keycloak.saml.common.PicketLinkLoggerFactory;
import org.keycloak.saml.common.constants.GeneralConstants;
import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request;
import org.keycloak.saml.processing.api.saml.v2.response.SAML2Response;
import org.keycloak.saml.processing.api.util.DeflateUtil;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.saml.processing.web.util.PostBindingUtil;
import org.keycloak.saml.processing.web.util.RedirectBindingUtil;
@ -42,26 +42,17 @@ public class SAMLRequestParser {
protected static Logger log = Logger.getLogger(SAMLRequestParser.class);
public static SAMLDocumentHolder parseRequestRedirectBinding(String samlMessage) {
InputStream is;
try {
is = RedirectBindingUtil.base64DeflateDecode(samlMessage);
} catch (IOException e) {
logger.samlBase64DecodingError(e);
return null;
}
if (log.isDebugEnabled()) {
String message = null;
try {
message = StreamUtil.readString(is, GeneralConstants.SAML_CHARSET);
} catch (IOException e) {
throw new RuntimeException(e);
}
log.debug("SAML Redirect Binding");
log.debug(message);
is = new ByteArrayInputStream(message.getBytes(GeneralConstants.SAML_CHARSET));
return parseRequestRedirectBinding(samlMessage, DeflateUtil.DEFAULT_MAX_INFLATING_SIZE);
}
}
try {
public static SAMLDocumentHolder parseRequestRedirectBinding(String samlMessage, long maxInflatingSize) {
try (InputStream is = RedirectBindingUtil.base64DeflateDecode(samlMessage, maxInflatingSize)) {
if (log.isDebugEnabled()) {
String message = StreamUtil.readString(is, GeneralConstants.SAML_CHARSET);
log.debug("SAML Redirect Binding");
log.debug(message);
return SAML2Request.getSAML2ObjectFromStream(new ByteArrayInputStream(message.getBytes(GeneralConstants.SAML_CHARSET)));
}
return SAML2Request.getSAML2ObjectFromStream(is);
} catch (Exception e) {
logger.samlBase64DecodingError(e);
@ -110,27 +101,20 @@ public class SAMLRequestParser {
}
public static SAMLDocumentHolder parseResponseRedirectBinding(String samlMessage) {
InputStream is;
try {
is = RedirectBindingUtil.base64DeflateDecode(samlMessage);
} catch (IOException e) {
logger.samlBase64DecodingError(e);
return null;
}
if (log.isDebugEnabled()) {
String message = null;
try {
message = StreamUtil.readString(is, GeneralConstants.SAML_CHARSET);
} catch (IOException e) {
throw new RuntimeException(e);
}
log.debug("SAML Redirect Binding");
log.debug(message);
is = new ByteArrayInputStream(message.getBytes(GeneralConstants.SAML_CHARSET));
return parseResponseRedirectBinding(samlMessage, DeflateUtil.DEFAULT_MAX_INFLATING_SIZE);
}
}
SAML2Response response = new SAML2Response();
try {
public static SAMLDocumentHolder parseResponseRedirectBinding(String samlMessage, long maxInflatingSize) {
try (InputStream is = RedirectBindingUtil.base64DeflateDecode(samlMessage, maxInflatingSize)) {
if (log.isDebugEnabled()) {
String message = StreamUtil.readString(is, GeneralConstants.SAML_CHARSET);
log.debug("SAML Redirect Binding");
log.debug(message);
SAML2Response response = new SAML2Response();
response.getSAML2ObjectFromStream(new ByteArrayInputStream(message.getBytes(GeneralConstants.SAML_CHARSET)));
return response.getSamlDocumentHolder();
}
SAML2Response response = new SAML2Response();
response.getSAML2ObjectFromStream(is);
return response.getSamlDocumentHolder();
} catch (Exception e) {

View file

@ -35,6 +35,15 @@ import org.keycloak.saml.common.constants.GeneralConstants;
*/
public class DeflateUtil {
/**
* Maximum size for inflating. Default is 128KB like quarkus.http.limits.max-form-attribute-size.
*/
public static long DEFAULT_MAX_INFLATING_SIZE = 131072;
private DeflateUtil() {
// utility class
}
/**
* Apply DEFLATE encoding
*
@ -75,7 +84,90 @@ public class DeflateUtil {
* @return
*/
public static InputStream decode(byte[] msgToDecode) {
return decode(msgToDecode, DEFAULT_MAX_INFLATING_SIZE);
}
/**
* DEFLATE decoding
*
* @param msgToDecode the message that needs decoding
* @param maxInflatingSize the maximum size to inflate, IOExceptio is thrown if more data is inflated
*
* @return
*/
public static InputStream decode(byte[] msgToDecode, long maxInflatingSize) {
ByteArrayInputStream bais = new ByteArrayInputStream(msgToDecode);
return new InflaterInputStream(bais, new Inflater(true));
return new LimitedInflaterInputStream(bais, maxInflatingSize);
}
private static class LimitedInflaterInputStream extends InputStream {
private final InflaterInputStream is;
private final Inflater inflater;
private final long maxInflatingSize;
private LimitedInflaterInputStream(InputStream is, long maxInflatingSize) {
this.inflater = new Inflater(true);
this.is = new InflaterInputStream(is, inflater);
this.maxInflatingSize = maxInflatingSize;
}
private void checkMaxInflatingsize() throws IOException {
if (inflater.getBytesWritten() > maxInflatingSize) {
throw new IOException(String.format("Maximum inflating size %d reached. Total bytes witten %d.",
maxInflatingSize, inflater.getTotalOut()));
}
}
@Override
public int read() throws IOException {
int result = is.read();
checkMaxInflatingsize();
return result;
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
int result = is.read(b, off, len);
checkMaxInflatingsize();
return result;
}
@Override
public int read(byte[] b) throws IOException {
int result = is.read(b);
checkMaxInflatingsize();
return result;
}
@Override
public boolean markSupported() {
return false;
}
@Override
public void reset() throws IOException {
throw new IOException("mark/reset not supported");
}
@Override
public void mark(int readlimit) {
// nothing
}
@Override
public void close() throws IOException {
is.close();
}
@Override
public int available() throws IOException {
return is.available();
}
@Override
public long skip(long n) throws IOException {
return is.skip(n);
}
}
}

View file

@ -152,8 +152,22 @@ public class RedirectBindingUtil {
* @throws IOException
*/
public static InputStream urlBase64DeflateDecode(String encodedString) throws IOException {
return urlBase64DeflateDecode(encodedString, DeflateUtil.DEFAULT_MAX_INFLATING_SIZE);
}
/**
* Apply URL decoding, followed by base64 decoding followed by deflate decompression
*
* @param encodedString
* @param maxInflatingSize
*
* @return
*
* @throws IOException
*/
public static InputStream urlBase64DeflateDecode(String encodedString, long maxInflatingSize) throws IOException {
byte[] deflatedString = urlBase64Decode(encodedString);
return DeflateUtil.decode(deflatedString);
return DeflateUtil.decode(deflatedString, maxInflatingSize);
}
/**
@ -166,8 +180,22 @@ public class RedirectBindingUtil {
* @throws IOException
*/
public static InputStream base64DeflateDecode(String encodedString) throws IOException {
return base64DeflateDecode(encodedString, DeflateUtil.DEFAULT_MAX_INFLATING_SIZE);
}
/**
* Base64 decode followed by Deflate decoding
*
* @param encodedString
* @param maxInflatingSize
*
* @return
*
* @throws IOException
*/
public static InputStream base64DeflateDecode(String encodedString, long maxInflatingSize) throws IOException {
byte[] base64decodedMsg = Base64.getMimeDecoder().decode(encodedString);
return DeflateUtil.decode(base64decodedMsg);
return DeflateUtil.decode(base64decodedMsg, maxInflatingSize);
}
/**

View file

@ -91,6 +91,7 @@ import org.keycloak.protocol.saml.SamlMetadataKeyLocator;
import org.keycloak.protocol.saml.SamlMetadataPublicKeyLoader;
import org.keycloak.protocol.saml.SamlPrincipalType;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.protocol.saml.SamlProtocolFactory;
import org.keycloak.protocol.saml.SamlProtocolUtils;
import org.keycloak.protocol.saml.SamlService;
import org.keycloak.protocol.saml.SamlSessionUtils;
@ -154,6 +155,7 @@ public class SAMLEndpoint {
protected final KeycloakSession session;
protected final ClientConnection clientConnection;
protected final HttpHeaders headers;
protected final long maxInflatingSize;
public SAMLEndpoint(KeycloakSession session, SAMLIdentityProvider provider, SAMLIdentityProviderConfig config, UserAuthenticationIdentityProvider.AuthenticationCallback callback, DestinationValidator destinationValidator) {
@ -165,6 +167,8 @@ public class SAMLEndpoint {
this.session = session;
this.clientConnection = session.getContext().getConnection();
this.headers = session.getContext().getRequestHeaders();
SamlProtocolFactory factory = (SamlProtocolFactory) session.getKeycloakSessionFactory().getProviderFactory(LoginProtocol.class, SamlProtocol.LOGIN_PROTOCOL);
this.maxInflatingSize = factory.getMaxInflatingSize();
}
@GET
@ -298,6 +302,12 @@ public class SAMLEndpoint {
protected Response handleSamlRequest(String samlRequest, String relayState) {
SAMLDocumentHolder holder = extractRequestDocument(samlRequest);
if (holder == null) {
event.event(EventType.IDENTITY_PROVIDER_RESPONSE);
event.detail(Details.REASON, Errors.INVALID_SAML_DOCUMENT);
event.error(Errors.INVALID_REQUEST);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
RequestAbstractType requestAbstractType = (RequestAbstractType) holder.getSamlObject();
// validate destination
if (isDestinationRequired() &&
@ -878,12 +888,12 @@ public class SAMLEndpoint {
@Override
protected SAMLDocumentHolder extractRequestDocument(String samlRequest) {
return SAMLRequestParser.parseRequestRedirectBinding(samlRequest);
return SAMLRequestParser.parseRequestRedirectBinding(samlRequest, maxInflatingSize);
}
@Override
protected SAMLDocumentHolder extractResponseDocument(String response) {
return SAMLRequestParser.parseResponseRedirectBinding(response);
return SAMLRequestParser.parseResponseRedirectBinding(response, maxInflatingSize);
}
@Override

View file

@ -40,10 +40,13 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.protocol.saml.mappers.AttributeStatementHelper;
import org.keycloak.protocol.saml.mappers.RoleListMapper;
import org.keycloak.protocol.saml.mappers.UserPropertyAttributeStatementMapper;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.representations.idm.CertificateRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.saml.SignatureAlgorithm;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.processing.api.util.DeflateUtil;
import org.keycloak.saml.processing.core.saml.v2.constants.X500SAMLProfileConstants;
import org.keycloak.saml.validators.DestinationValidator;
@ -57,10 +60,11 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory {
private static final String ROLE_LIST_CONSENT_TEXT = "${samlRoleListScopeConsentText}";
private DestinationValidator destinationValidator;
private long maxInflatingSize;
@Override
public Object createProtocolEndpoint(KeycloakSession session, EventBuilder event) {
return new SamlService(session, event, destinationValidator);
return new SamlService(session, event, maxInflatingSize, destinationValidator);
}
@Override
@ -103,6 +107,7 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory {
defaultBuiltins.add(model);
}
this.destinationValidator = DestinationValidator.forProtocolMap(config.getArray("knownProtocols"));
this.maxInflatingSize = config.getLong("maxInflatingSize", DeflateUtil.DEFAULT_MAX_INFLATING_SIZE);
}
@Override
@ -202,4 +207,24 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory {
public int order() {
return OIDCLoginProtocolFactory.UI_ORDER - 10;
}
/**
* Getter for the max inflating size
* @return
*/
public long getMaxInflatingSize() {
return maxInflatingSize;
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return ProviderConfigurationBuilder.create()
.property()
.name("maxInflatingSize")
.type("long")
.helpText("The maximum inflating size in bytes for the REDIRECT binding.")
.defaultValue(DeflateUtil.DEFAULT_MAX_INFLATING_SIZE)
.add()
.build();
}
}

View file

@ -159,10 +159,12 @@ public class SamlService extends AuthorizationEndpointBase {
public static final String ARTIFACT_RESOLUTION_SERVICE_PATH = "resolve";
private final DestinationValidator destinationValidator;
private final long maxInflatingSize;
public SamlService(KeycloakSession session, EventBuilder event, DestinationValidator destinationValidator) {
public SamlService(KeycloakSession session, EventBuilder event, long maxInflatingSize, DestinationValidator destinationValidator) {
super(session, event);
this.destinationValidator = destinationValidator;
this.maxInflatingSize = maxInflatingSize;
}
public abstract class BindingProtocol {
@ -204,7 +206,7 @@ public class SamlService extends AuthorizationEndpointBase {
event.event(EventType.LOGOUT);
SAMLDocumentHolder holder = extractResponseDocument(samlResponse);
if (! (holder.getSamlObject() instanceof StatusResponseType)) {
if (holder == null || !(holder.getSamlObject() instanceof StatusResponseType)) {
event.detail(Details.REASON, Errors.INVALID_SAML_RESPONSE);
event.error(Errors.INVALID_SAML_RESPONSE);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
@ -848,12 +850,12 @@ public class SamlService extends AuthorizationEndpointBase {
@Override
protected SAMLDocumentHolder extractRequestDocument(String samlRequest) {
return SAMLRequestParser.parseRequestRedirectBinding(samlRequest);
return SAMLRequestParser.parseRequestRedirectBinding(samlRequest, maxInflatingSize);
}
@Override
protected SAMLDocumentHolder extractResponseDocument(String response) {
return SAMLRequestParser.parseResponseRedirectBinding(response);
return SAMLRequestParser.parseResponseRedirectBinding(response, maxInflatingSize);
}
@Override
@ -1120,7 +1122,7 @@ public class SamlService extends AuthorizationEndpointBase {
@NoCache
@Consumes({"application/soap+xml",MediaType.TEXT_XML})
public Response soapBinding(InputStream inputStream) {
SamlEcpProfileService bindingService = new SamlEcpProfileService(session, event, destinationValidator);
SamlEcpProfileService bindingService = new SamlEcpProfileService(session, event, maxInflatingSize, destinationValidator);
return bindingService.authenticate(inputStream);
}

View file

@ -59,8 +59,8 @@ public class SamlEcpProfileService extends SamlService {
private static final String NS_PREFIX_SAML_PROTOCOL = "samlp";
private static final String NS_PREFIX_SAML_ASSERTION = "saml";
public SamlEcpProfileService(KeycloakSession session, EventBuilder event, DestinationValidator destinationValidator) {
super(session, event, destinationValidator);
public SamlEcpProfileService(KeycloakSession session, EventBuilder event, long maxInflatingSize, DestinationValidator destinationValidator) {
super(session, event, maxInflatingSize, destinationValidator);
}
public Response authenticate(InputStream inputStream) {

View file

@ -17,12 +17,14 @@
package org.keycloak.test.broker.saml;
import java.io.IOException;
import java.util.Base64;
import org.keycloak.saml.SAMLRequestParser;
import org.keycloak.saml.common.constants.GeneralConstants;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.saml.processing.web.util.PostBindingUtil;
import org.keycloak.saml.processing.web.util.RedirectBindingUtil;
import org.junit.Assert;
import org.junit.Test;
@ -35,6 +37,7 @@ import org.junit.Test;
public class SAMLParsingTest {
private static final String SAML_RESPONSE = "<samlp:LogoutResponse xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" xmlns=\"urn:oasis:names:tc:SAML:2.0:assertion\" Destination=\"http://localhost:8081/auth/realms/realm-with-broker/broker/kc-saml-idp-basic/endpoint\" ID=\"ID_9a171d23-c417-42f5-9bca-c093123fd68c\" InResponseTo=\"ID_bc730711-2037-43f3-ad76-7bc33842fb87\" IssueInstant=\"2016-02-29T12:00:14.044Z\" Version=\"2.0\"><saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">http://localhost:8082/auth/realms/realm-with-saml-idp-basic</saml:Issuer><samlp:Status><samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></samlp:Status></samlp:LogoutResponse>";
private static final String SAML_REQUEST = "<samlp:AuthnRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" xmlns=\"urn:oasis:names:tc:SAML:2.0:assertion\" xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" AssertionConsumerServiceURL=\"http://localhost:8080/realms/master/broker/saml/endpoint\" AttributeConsumingServiceIndex=\"0\" Destination=\"http://localhost:8080/realms/saml/protocol/saml\" ForceAuthn=\"false\" ID=\"ID_7228aef5-4a58-4481-a371-30e4ad7e98f4\" IssueInstant=\"2026-02-16T11:23:32.472Z\" ProtocolBinding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Version=\"2.0\"><saml:Issuer>http://localhost:8080/realms/master</saml:Issuer><samlp:NameIDPolicy AllowCreate=\"true\" Format=\"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\"/></samlp:AuthnRequest>";
@Test
public void parseTest() {
@ -51,4 +54,19 @@ public class SAMLParsingTest {
SAMLDocumentHolder holder = SAMLRequestParser.parseResponseDocument(samlBytes);
Assert.assertNotNull(holder);
}
@Test
public void parseRequestResponseRedirectBinding() throws IOException {
String encodedResponse = RedirectBindingUtil.deflateBase64Encode(SAML_RESPONSE.getBytes(GeneralConstants.SAML_CHARSET_NAME));
SAMLDocumentHolder holder = SAMLRequestParser.parseResponseRedirectBinding(encodedResponse, SAML_RESPONSE.length());
Assert.assertNotNull(holder);
holder = SAMLRequestParser.parseResponseRedirectBinding(encodedResponse, SAML_RESPONSE.length() - 1);
Assert.assertNull(holder);
String encodedRequest = RedirectBindingUtil.deflateBase64Encode(SAML_REQUEST.getBytes(GeneralConstants.SAML_CHARSET_NAME));
holder = SAMLRequestParser.parseRequestRedirectBinding(encodedRequest, SAML_REQUEST.length());
Assert.assertNotNull(holder);
holder = SAMLRequestParser.parseRequestRedirectBinding(encodedRequest, SAML_RESPONSE.length() - 1);
Assert.assertNull(holder);
}
}