[KEYCLOAK-12612][KEYCLOAK-12944] Fix validation of SAML destination URLs
- no longer compare them to the server absolutePath; instead use the base URI to build the validation URL
This commit is contained in:
parent
eeeaafb5e7
commit
7a3998870c
3 changed files with 168 additions and 4 deletions
|
@ -41,6 +41,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.KeyManager;
|
import org.keycloak.models.KeyManager;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.KeycloakUriInfo;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserSessionModel;
|
import org.keycloak.models.UserSessionModel;
|
||||||
import org.keycloak.protocol.AuthorizationEndpointBase;
|
import org.keycloak.protocol.AuthorizationEndpointBase;
|
||||||
|
@ -55,6 +56,7 @@ import org.keycloak.saml.common.constants.GeneralConstants;
|
||||||
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
|
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
|
||||||
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
|
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
|
||||||
import org.keycloak.services.ErrorPage;
|
import org.keycloak.services.ErrorPage;
|
||||||
|
import org.keycloak.services.Urls;
|
||||||
import org.keycloak.services.managers.AuthenticationManager;
|
import org.keycloak.services.managers.AuthenticationManager;
|
||||||
import org.keycloak.services.messages.Messages;
|
import org.keycloak.services.messages.Messages;
|
||||||
import org.keycloak.services.resources.RealmsResource;
|
import org.keycloak.services.resources.RealmsResource;
|
||||||
|
@ -92,7 +94,6 @@ import org.keycloak.saml.processing.core.util.KeycloakKeySamlExtensionGenerator;
|
||||||
import org.keycloak.saml.validators.DestinationValidator;
|
import org.keycloak.saml.validators.DestinationValidator;
|
||||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.logging.Level;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resource class for the saml connect token service
|
* Resource class for the saml connect token service
|
||||||
|
@ -151,7 +152,7 @@ public class SamlService extends AuthorizationEndpointBase {
|
||||||
|
|
||||||
StatusResponseType statusResponse = (StatusResponseType) holder.getSamlObject();
|
StatusResponseType statusResponse = (StatusResponseType) holder.getSamlObject();
|
||||||
// validate destination
|
// validate destination
|
||||||
if (! destinationValidator.validate(session.getContext().getUri().getAbsolutePath(), statusResponse.getDestination())) {
|
if (! destinationValidator.validate(this.getExpectedDestinationUri(session), statusResponse.getDestination())) {
|
||||||
event.detail(Details.REASON, "invalid_destination");
|
event.detail(Details.REASON, "invalid_destination");
|
||||||
event.error(Errors.INVALID_SAML_LOGOUT_RESPONSE);
|
event.error(Errors.INVALID_SAML_LOGOUT_RESPONSE);
|
||||||
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
|
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
|
||||||
|
@ -288,7 +289,7 @@ public class SamlService extends AuthorizationEndpointBase {
|
||||||
event.error(Errors.INVALID_SAML_AUTHN_REQUEST);
|
event.error(Errors.INVALID_SAML_AUTHN_REQUEST);
|
||||||
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
|
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
if (! destinationValidator.validate(session.getContext().getUri().getAbsolutePath(), requestAbstractType.getDestination())) {
|
if (! destinationValidator.validate(this.getExpectedDestinationUri(session), requestAbstractType.getDestination())) {
|
||||||
event.detail(Details.REASON, "invalid_destination");
|
event.detail(Details.REASON, "invalid_destination");
|
||||||
event.error(Errors.INVALID_SAML_AUTHN_REQUEST);
|
event.error(Errors.INVALID_SAML_AUTHN_REQUEST);
|
||||||
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
|
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
|
||||||
|
@ -402,7 +403,7 @@ public class SamlService extends AuthorizationEndpointBase {
|
||||||
event.error(Errors.INVALID_SAML_LOGOUT_REQUEST);
|
event.error(Errors.INVALID_SAML_LOGOUT_REQUEST);
|
||||||
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
|
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
if (! destinationValidator.validate(logoutRequest.getDestination(), session.getContext().getUri().getAbsolutePath())) {
|
if (! destinationValidator.validate(this.getExpectedDestinationUri(session), logoutRequest.getDestination())) {
|
||||||
event.detail(Details.REASON, "invalid_destination");
|
event.detail(Details.REASON, "invalid_destination");
|
||||||
event.error(Errors.INVALID_SAML_LOGOUT_REQUEST);
|
event.error(Errors.INVALID_SAML_LOGOUT_REQUEST);
|
||||||
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
|
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
|
||||||
|
@ -517,6 +518,18 @@ public class SamlService extends AuthorizationEndpointBase {
|
||||||
else
|
else
|
||||||
return handleSamlResponse(samlResponse, relayState);
|
return handleSamlResponse(samlResponse, relayState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KEYCLOAK-12616, KEYCLOAK-12944: construct the expected destination URI using the configured base URI.
|
||||||
|
*
|
||||||
|
* @param session a reference to the {@link KeycloakSession}.
|
||||||
|
* @return the constructed {@link URI}.
|
||||||
|
*/
|
||||||
|
protected URI getExpectedDestinationUri(final KeycloakSession session) {
|
||||||
|
final String realmName = session.getContext().getRealm().getName();
|
||||||
|
final URI baseUri = session.getContext().getUri().getBaseUri();
|
||||||
|
return Urls.samlRequestEndpoint(baseUri, realmName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected class PostBindingProtocol extends BindingProtocol {
|
protected class PostBindingProtocol extends BindingProtocol {
|
||||||
|
|
|
@ -20,6 +20,7 @@ import org.keycloak.common.Version;
|
||||||
import org.keycloak.models.Constants;
|
import org.keycloak.models.Constants;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
|
||||||
|
import org.keycloak.protocol.saml.SamlProtocol;
|
||||||
import org.keycloak.services.resources.account.AccountFormService;
|
import org.keycloak.services.resources.account.AccountFormService;
|
||||||
import org.keycloak.services.resources.IdentityBrokerService;
|
import org.keycloak.services.resources.IdentityBrokerService;
|
||||||
import org.keycloak.services.resources.LoginActionsService;
|
import org.keycloak.services.resources.LoginActionsService;
|
||||||
|
@ -268,4 +269,8 @@ public class Urls {
|
||||||
private static UriBuilder themeBase(URI baseUri) {
|
private static UriBuilder themeBase(URI baseUri) {
|
||||||
return UriBuilder.fromUri(baseUri).path(ThemeResource.class);
|
return UriBuilder.fromUri(baseUri).path(ThemeResource.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static URI samlRequestEndpoint(final URI baseUri, final String realmName) {
|
||||||
|
return realmBase(baseUri).path(RealmsResource.class, "getProtocol").build(realmName, SamlProtocol.LOGIN_PROTOCOL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,146 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package org.keycloak.testsuite.saml;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
|
import javax.ws.rs.core.Response;
|
||||||
|
import javax.ws.rs.core.UriBuilder;
|
||||||
|
|
||||||
|
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||||
|
import org.apache.http.client.methods.HttpUriRequest;
|
||||||
|
import org.apache.http.impl.client.CloseableHttpClient;
|
||||||
|
import org.apache.http.impl.client.HttpClientBuilder;
|
||||||
|
import org.apache.http.util.EntityUtils;
|
||||||
|
import org.hamcrest.Matcher;
|
||||||
|
import org.junit.ClassRule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.keycloak.protocol.saml.SamlProtocol;
|
||||||
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
|
import org.keycloak.saml.SAML2LogoutRequestBuilder;
|
||||||
|
import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request;
|
||||||
|
import org.keycloak.services.resources.RealmsResource;
|
||||||
|
import org.keycloak.testsuite.util.ReverseProxy;
|
||||||
|
import org.keycloak.testsuite.util.SamlClient;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.junit.Assert.assertThat;
|
||||||
|
import static org.keycloak.testsuite.util.Matchers.statusCodeIsHC;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAML tests using a {@code frontendUrl} that points to a reverse proxy. The SAML request destination should be validated
|
||||||
|
* against the proxy address and any redirection should also have the proxy as target.
|
||||||
|
*
|
||||||
|
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||||
|
*/
|
||||||
|
public class SamlReverseProxyTest extends AbstractSamlTest {
|
||||||
|
|
||||||
|
@ClassRule
|
||||||
|
public static ReverseProxy proxy = new ReverseProxy();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KEYCLOAK-12612
|
||||||
|
*
|
||||||
|
* Tests sending a SAML {@code AuthnRequest} through a reverse proxy. In this scenario the SAML {@code AuthnRequest}
|
||||||
|
* has a destination that matches the proxy server, but the request is forwarded to a keycloak server running in a
|
||||||
|
* different address.
|
||||||
|
*
|
||||||
|
* Validation of the destination and subsequent redirection to the login screen only work if the proxy server is configured
|
||||||
|
* as the {@code frontendUrl} of the realm.
|
||||||
|
*
|
||||||
|
* @throws Exception if an error occurs while running the test.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testAuthnRequestWithReverseProxy() throws Exception {
|
||||||
|
// send an authn request without defining the frontendUrl for the realm - should get a BAD_REQUEST response
|
||||||
|
Document document = SAML2Request.convert(SamlClient.createLoginRequestDocument(SAML_CLIENT_ID_SALES_POST,
|
||||||
|
SAML_ASSERTION_CONSUMER_URL_SALES_POST, this.buildSamlProtocolUrl(proxy.getUrl())));
|
||||||
|
testSendSamlRequest(document, Response.Status.BAD_REQUEST, containsString("Invalid Request"));
|
||||||
|
|
||||||
|
// set the frontendUrl pointing to the reverse proxy
|
||||||
|
RealmRepresentation rep = adminClient.realm(REALM_NAME).toRepresentation();
|
||||||
|
try {
|
||||||
|
if (rep.getAttributes() == null) {
|
||||||
|
rep.setAttributes(new HashMap<>());
|
||||||
|
}
|
||||||
|
rep.getAttributes().put("frontendUrl", proxy.getUrl());
|
||||||
|
adminClient.realm(REALM_NAME).update(rep);
|
||||||
|
|
||||||
|
// resend the authn request - should succeed this time
|
||||||
|
testSendSamlRequest(document, Response.Status.OK, containsString("login"));
|
||||||
|
} finally {
|
||||||
|
// restore the state of the realm (unset the frontendUrl)
|
||||||
|
rep.getAttributes().remove("frontendUrl");
|
||||||
|
adminClient.realm(REALM_NAME).update(rep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KEYCLOAK-12944
|
||||||
|
*
|
||||||
|
* Tests sending a SAML {@code LogoutRequest} through a reverse proxy. In this scenario the SAML {@code LogoutRequest}
|
||||||
|
* has a destination that matches the proxy server, but the request is forwarded to a keycloak server running in a
|
||||||
|
* different address.
|
||||||
|
*
|
||||||
|
* Validation of the destination and any subsequent redirection only work if the proxy server is configured as the
|
||||||
|
* {@code frontendUrl} of the realm.
|
||||||
|
*
|
||||||
|
* @throws Exception if an error occurs while running the test.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testLogoutRequestWithReverseProxy() throws Exception {
|
||||||
|
// send a logout request without defining the frontendUrl for the realm - should get a BAD_REQUEST response
|
||||||
|
Document document = new SAML2LogoutRequestBuilder().destination(
|
||||||
|
this.buildSamlProtocolUrl(proxy.getUrl()).toString()).issuer(SAML_CLIENT_ID_SALES_POST).buildDocument();
|
||||||
|
testSendSamlRequest(document, Response.Status.BAD_REQUEST, containsString("Invalid Request"));
|
||||||
|
|
||||||
|
// set the frontendUrl pointing to the reverse proxy
|
||||||
|
RealmRepresentation rep = adminClient.realm(REALM_NAME).toRepresentation();
|
||||||
|
try {
|
||||||
|
if (rep.getAttributes() == null) {
|
||||||
|
rep.setAttributes(new HashMap<>());
|
||||||
|
}
|
||||||
|
rep.getAttributes().put("frontendUrl", proxy.getUrl());
|
||||||
|
adminClient.realm(REALM_NAME).update(rep);
|
||||||
|
|
||||||
|
// resend the logout request - should succeed this time (we are actually not logging out anyone, just checking the request is properly validated
|
||||||
|
testSendSamlRequest(document, Response.Status.OK, containsString("login"));
|
||||||
|
} finally {
|
||||||
|
// restore the state of the realm (unset the frontendUrl)
|
||||||
|
rep.getAttributes().remove("frontendUrl");
|
||||||
|
adminClient.realm(REALM_NAME).update(rep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void testSendSamlRequest(final Document doc, final Response.Status expectedHttpCode, final Matcher<String> pageTextMatcher) throws Exception {
|
||||||
|
HttpUriRequest post =
|
||||||
|
SamlClient.Binding.POST.createSamlUnsignedRequest(this.buildSamlProtocolUrl(proxy.getUrl()), null, doc);
|
||||||
|
try (CloseableHttpClient client = HttpClientBuilder.create().setSSLHostnameVerifier((s, sslSession) -> true).
|
||||||
|
setRedirectStrategy(new SamlClient.RedirectStrategyWithSwitchableFollowRedirect()).build();
|
||||||
|
CloseableHttpResponse response = client.execute(post)) {
|
||||||
|
assertThat(response, statusCodeIsHC(expectedHttpCode));
|
||||||
|
assertThat(EntityUtils.toString(response.getEntity(), "UTF-8"), pageTextMatcher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private URI buildSamlProtocolUrl(final String baseUri) {
|
||||||
|
return RealmsResource.protocolUrl(UriBuilder.fromUri(baseUri)).build(REALM_NAME, SamlProtocol.LOGIN_PROTOCOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in a new issue