Expected Behavior

WebSessionServerRequestCache or another ServerRequestCache implementation should support saving POST requests for replaying after authentication is successful.

Current Behavior

WebSessionServerRequestCache only supports saving GET requests. It can be extended to support matching other requests with setSaveRequestMatcher() but it will only be able to save/replay the original GET url.

Context

We have internally implemented SAML in spring security by creating my own AuthenticationWebFilter that is configured to create and validate SAML requests and responses. Our SAML supports configuration as both an IDP and SP. The SAML core is implemented around org.opensaml XML and security api's. This is as requested in issue https://github.com/spring-projects/spring-security/issues/7954

Background reading on SAML: http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html

See section: SP-Initiated SSO: Redirect/POST Bindings

When our server is configured as an IDP, a POST request may come to our server with a request for SAML verification. Our IDP endpoint looks like this:

@RestController
@RequestMapping("/saml/idp")
public class SamlIdpController {
    @PostMapping(value = "/ls", produces = MediaType.TEXT_HTML_VALUE)
    public Mono<String> loginService(Principal principal, ServerWebExchange exchange) {
        return exchange.getFormData().flatMap(formData -> {
            String samlRequest = formData.getFirst("SAMLRequest");
            String relayState = formData.getFirst("RelayState");
            if (samlRequest != null) {
                return processLoginRequest("POST", principal, samlRequest, relayState);
            }
            logger.info("Authentication did not succeed for SAMLRequest: {} from {}", samlRequest, getRemoteHostAddress(exchange.getRequest()));
            return Mono.empty();
        });
    }
}

Before the POST can be processed the authentication is handled with AuthenticationWebFilter . If the users is not authenticated they will be redirected for authentication. i.e. LoginForm/BasicAuth. Then they "should" be redirected back to the SAML IDP endpoint.

This doesn't doesn't happen because the WebSessionServerRequestCache wired into AuthenticationWebFilter doesn't support replaying the SAML POST request.

Workaround

I have implemented a PostSavingWebSessionRequestCache. The does something like the below, and with another hack it works:

public class PostSavingWebSessionRequestCache implements ServerRequestCache {

    @Override
    public Mono<Void> saveRequest(ServerWebExchange exchange) {
        String requestPath = pathInApplication(exchange.getRequest());
        if (exchange.getRequest().getMethod() == POST) {
            return this.postMatcher.matches(exchange).filter(ServerWebExchangeMatcher.MatchResult::isMatch)
                    .flatMap((m) -> exchange.getSession()).map(WebSession::getAttributes).doOnNext((attrs) -> {
                        attrs.put(SAVED_PATH_ATTR, requestPath);
                        attrs.put(SAVED_REQUEST_ATTR, serializePost(exchange));
                        logger.info("POST Request added to WebSession: {}", requestPath);
                    }).then();
        } else {
            return this.getMatcher.matches(exchange).filter(ServerWebExchangeMatcher.MatchResult::isMatch)
                    .flatMap((m) -> exchange.getSession()).map(WebSession::getAttributes).doOnNext((attrs) -> {
                        attrs.put(SAVED_PATH_ATTR, requestPath);
                        logger.info("{} Request added to WebSession: {}", exchange.getRequest().getMethod(),
                                requestPath);
                    }).then();
        }
    }

    @Override
    public Mono<ServerHttpRequest> removeMatchingRequest(ServerWebExchange exchange) {
        return exchange.getSession().map(WebSession::getAttributes).flatMap((attributes) -> {
            String requestPath = pathInApplication(exchange.getRequest());
            boolean removed = attributes.remove(SAVED_PATH_ATTR, requestPath);
            if (removed) {
                logger.debug(LogMessage.format("Request removed from WebSession: '%s'", requestPath));
            }
            if (removed) {
                ServerHttpRequest request = (ServerHttpRequest) attributes.remove(SAVED_REQUEST_ATTR);
                if (request == null) {
                    logger.info("{} Request continuing: {}", exchange.getRequest().getMethod(), requestPath);
                    return Mono.just(exchange.getRequest());
                } else {
                    logger.info("POST Request replaying from cache: {}", requestPath);
                    return Mono.just(request);
                }
            } else {
                return Mono.empty();
            }
        });
    }
}

It would be nice if something like this existed out of the box in spring security.

Possible Concerns - There may not be many use cases other than mine, but I did see other discussions here: https://stackoverflow.com/questions/21958224/how-to-enable-spring-security-post-redirect-after-log-in-with-csrf - There may be security issue with saving post details, also note SAML posts need to bypass CSRF. - It is hard to to serialize/save the formData in the reactive request. I end up actually converting the POST to a GET to get it to work.

Comment From: jzheaux

Hi, @stffrdhrn.

I'm not sure if I've got your idea right, but I think what you are proposing is that the authentication success handler redirect using a GET to your saved POST, and then during the request, you alter it the request from a GET to a POST when you look it up from the request cache. Is that right?

Comment From: stffrdhrn

Hi @jzheaux,

Yes, that is the solution I came up with, now that I read the above I didn't give a good explaination of the code. It is what we are using. But, it seems a bit hacky.

For incoming requests that are not authenticated we want them to be authenticated, after being authenticated the request shall continue. For GET requests this works, because the case is implemented. However for POST request this does not work as it is not implemented.

I used the intermediate GET request (created by serializePost()) because when using WebFlux the POST request body needs to be fully read to be saved. There are a few issues issues with this, namely:

  • To read the POST request in serializePost() I did a blocking read. (Implemented with a flatMap and a latch). If we don't block there can be race conditions where the data is not read before the request returns.
  • This might cause security issues because reading in an entire POST body. The size of the post request may be over the external servers HTTP header limit bad actors could consume a lot of memory. So if we do do that we might want to limit how big we want to allow the POST request's to be.

I couldn't think of a better solution though.

Comment From: rwinch

POST methods should also be protected by CSRF protection and if the request is saved and replayed that means the endpoint is likely vulnerable to CSRF attacks or it will fail with an invalid CSRF token.

Comment From: stffrdhrn

@rwinch this is true, accepting POST requests without csrf would be vulnerable to csrf attacks. Unfortunately this is a requirement of the SAML protocol. The SAML protocol does verify that the posted data does come from a trusted party based on the ssl signature and the requests are time boxed.

Comment From: rwinch

@stffrdhrn Can you help me understand why the RequestCache would save the SAML post? The RequestCache saves a request prior to authentication to replay it after authentication happens. If the request contains a SAML assertion I don't know why it would be cached to replay after authentication (it is the request that is being authenticated).

Comment From: stffrdhrn

Hi @rwinch ,

The reason is because in this case we are doing saml authentication proxying (for lack of a better word). Our first SAML request cannot be authenticated as is, we need to forward the the user to a second identity provider IDP.

The interaction is something like the below:

               User browser session
  ------------------------------------------------
   (1)(10) (2)(9) (3)(8)      (4)(7)  (5) (6)
    \ ^    ^ /     \  ^        ^ /      \   ^
     \ \  / /       \  \      / /        \   \
      V \/ V         V  \    /  V         V   \
   [ SP 1  ]      | IDP 1 / SP 2 |      | Corp. IDP |
   [ App x ]      | App y        |      |           |

Prereq: - App x is a service provider (SP 1), it has been registered with Identity provider IDP 1. - SP2 is a service provider, it has been registered with the corporate identity provider Corp IDP. - IDP 1 cannot authenticate requests on its own, it delegates to Corp IDP. - IDP 1 is also a service provider (SP 2), (for proxying authentication requests to Corp IDP)

Interaction: 1. User makes an request to App x 2. The user is not authenticated they are redirected for authentication (served an html page with javascript post) 3. The user browser posts to IDP 1 a signed SAMLRequest 4. The user is not authenticated at IDP 1 , so they are redirected again 5. The user browser posts to Corp IDP a signed SAMLRequest 6. The user authenticates with Corp IDP i.e. password form/NTLM/Kerberos etc. and the SAML assertion is returned 7. The user browser posts to SP 2 the SAML assertion, authenticating the user 8. Now the user is authenticated the original post request from 3 can be replayed for authentication. and the SAML assertion is returned 9. The user browser posts to SP 1 the SAML assertion, authenticating the user 10. The original request from 1 can be served

Now, some possible questions: 1. Why doesn't SP1 just register with the Corp IDP? Its company issues, we have to hire a contractor lots of $$$ to touch the secure Corp IDP. 2. Is this supported by SAML? Saml requires that the IDP be able to authenticate the user, it doesn't specify how. We use a second SAML transaction to authenticate.

Comment From: rwinch

@stffrdhrn Thank you for your response. This helps me to understand how your system is working better.

A few clarifying questions/comments:

It sounds like in step #3 the IDP 1 does not yet validate the SAMLRequest but instead waits till step #8? The reason I am assuming this is because that to me would be the only reason you need to save the POST.

It seems like if in step #3 IDP 1 could:

a) Validate the SAMLRequest before sending the browser to CORP IDP b) Save in the session the fact that the SAMLRequest was valid and any information necessary for sending the SAMLResponse to SP 1 c) Redirect to a protected URL that would use the information from b to send the SAMLResponse after the user is authenticated.

To me this is better for a number of reasons:

a) You no longer need to persist the entire HTTP POST request. Instead a the redirect to the protected URL for c is saved b) The SAMLRequest is validated sooner which improves the user experience and security.

If the SAMLRequest is invalid, then, among other things, redirecting prior to validating the SAMLRequest opens up the applications to open redirect attacks.

Validating SAMLRequest improves the user experience because if the SAMLRequest is invalid, the user doesn't need to go through additional steps (i.e. type username/password) only to find out that the SAMLRequest was invalid. Along the same lines, SAMLRequest is typically a very short lived assertion because it is stateless. If the SAMLRequest is not validated until after being authenticated, a user that gets distracted and takes a long time to authenticate would perhaps have an expired SAMLRequest by the time they authenticate. If the SAMLRequest is validated in #3 the result of the validation could be stored in session. Since sessions are stateful, it is safer for them to be longer lived than the assertion and thus a user that takes a while to authenticate could more safely continue to authenticate without errors.

Comment From: stffrdhrn

It sounds like in step #3 the IDP 1 does not yet validate the SAMLRequest but instead waits till step #8? The reason I am assuming this is because that to me would be the only reason you need to save the POST.

Yes, that is right, replay happens at #8 finally.

It seems like if in step #3 IDP 1 could:

a) Validate the SAMLRequest before sending the browser to CORP IDP b) Save in the session the fact that the SAMLRequest was valid and any information necessary for sending the SAMLResponse to SP 1 c) Redirect to a protected URL that would use the information from b to send the SAMLResponse after the user is authenticated.

This is a great idea. I hadn't thought of it. It seems the way that spring authentication is currently implemented is it will 1. save the initial request, 2. authenticate, 3. then replay the initial request. Do you think this can be changed easily to do as you suggest?

One concern is that this could allow for some sort of DoS attack where bad actors (unauthenticated) could send invalid SAMLRequeest which will go though expensive certificate validation. This might not be such a big issue as this feature will likely be very limited is use.

Comment From: rwinch

This is a great idea. I hadn't thought of it. It seems the way that spring authentication is currently implemented is it will 1. save the initial request, 2. authenticate, 3. then replay the initial request. Do you think this can be changed easily to do as you suggest?

You have a few options:

1) The RequestCache only saves a URL that requires authentication and the user is not authenticated yet. This means you can make the URL to validate the SAMLRequest public. There is no harm in this because the SAMLRequest will be validated based on the signature.

2) If the logic for validating SAMLRequest can be implemented in a Filter, then you can place it in a Filter that happens before authorization happens. This would also ensure that the RequestCache never saves the URL for SAMLRequest processing.

One concern is that this could allow for some sort of DoS attack where bad actors (unauthenticated) could send invalid SAMLRequeest which will go though expensive certificate validation. This might not be such a big issue as this feature will likely be very limited is use.

This seems very unlikely to cause problems as RPs send a SAMLRequest to the IDP without the user being authenticated. What's more is someone can already submit SAMLRequests directly by creating a SAMLRequest that is signed by their own certificate and send it to the IDP causing the SAMLRequest signature to be validated.

Comment From: stffrdhrn

That sounds fine. If you think that can all be done without modifying the authentication filter then feel free to close the ticket.

Yes, and good point on the IDP also being exposed to DoS attacks its the same thing.