IonutB
IonutB

Reputation: 85

How to secure microservice websocket endpoint with Spring Cloud Gateway?

I have a microservice architecture based project, for which I have added Spring Cloud Gateway in front. My plan is to centralize security in the gateway. I have added OIDC and OAuth 2 for authentication and authorization which work as expected. The gateway plays the role of OAuth client and resource server. It uses authorization code flow. I have used TokenRelay filter to pass the information to downstream services. I have also enabled CORS and CSRF (enabled by default) in the gateway.

The issue I am having is more related to CSRF than anything else. For regular http calls, everything seems to work just fine, as the downstream service checks for Authorization header (which is added via TokenRelay) and it seems to skip CSRF check, essentially trusting the gateway. I would like the same thing for websocket connections. Currently, if I enable websocket security (via @EnableWebsocketSecurity), it will enable CSRF check, but sending the gateway’s token will throw an exception, as it expects the downstream service’s token (it has its own token repository). If I disable websocket security (and CSRF implicitly), sending/not sending the gateway’s token makes no difference.

Is there a way to achieve the same behavior from http calls in websockets with gateway? I know one option would be to create some sort of centralized token repository and use it in both gateway and microservice, but it seems like a hassle.

My current solution is to disable CSRF for microservice, as I am not sure how CSRF token helps in case of websockets, as Spring Security/Spring Websockets implements server side Origin header check (server side same origin policy), which seems to be enough. I have already posted about this here.

LATER EDIT

For HTTP requests, the CsrfFilter will bypass csrf token check if there is a Bearer token present. This is due to the fact that the microservice is an oauth resource server (see explanation on OAuth2ResourceServerConfigurer.registerDefaultCsrfOverride). However this does not happen for websockets. Is this intended or is it a bug?

Below is the code:

Downstream service

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@EnableWebSocketSecurity
public class DownstreamServiceSecurityConfiguration {


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
                .csrf(csrfSpec -> csrfSpec.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
                .oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults()));
        return http.build();
    }

.....

Gateway

@Configuration
@EnableWebFluxSecurity
public class GatewaySecurityConfiguration {

    @Autowired
    public SecurityConfiguration(ReactiveClientRegistrationRepository clientRegistrationRepository) {
        this.clientRegistrationRepository = clientRegistrationRepository;
    }

    ReactiveClientRegistrationRepository clientRegistrationRepository;

    private ServerLogoutSuccessHandler serverLogoutSuccessHandler() {
        OidcClientInitiatedServerLogoutSuccessHandler successHandler = new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository);
        //needs to match the one declared at auth-server
        successHandler.setPostLogoutRedirectUri("{baseUrl}/api-docs");
        return successHandler;
    }

    @Bean
    public SecurityWebFilterChain filterChain(ServerHttpSecurity http){

        http.cors(withDefaults())
                // add csrf token that can be handled by js clients by using CookieServerCsrfTokenRepository.withHttpOnlyFalse()
                .csrf(csrfSpec -> csrfSpec.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())
                        .csrfTokenRequestHandler(new SpaServerCsrfTokenRequestHandler()))
                .authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec.pathMatchers("/api-docs/**", "/swagger-ui.html", "/webjars/swagger-ui/**", "/actuator/**", "/oidc/**").permitAll()
                        .pathMatchers("/notification/ws-connect").hasAuthority("SCOPE_customer-read")
                        .anyExchange().authenticated())
                //handle oidc authentication by redirecting to auth server login page
                .oauth2Login(withDefaults())
                //make gateway also a resource server that supports jwt bearer token, as the default configuration does not kick in because we also have the client dependency on the classpath
                .oauth2ResourceServer(oAuth2ResourceServerSpec -> oAuth2ResourceServerSpec.jwt(withDefaults()))
                .logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer.logoutSuccessHandler(serverLogoutSuccessHandler()));
        return http.build();
    }

    //When storing the expected CSRF token in a cookie, JavaScript applications will only have access to the plain token value and will not have access to the encoded value.
    //A customized request handler for resolving the actual token value will need to be provided.
    //See https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa
    static final class SpaServerCsrfTokenRequestHandler extends ServerCsrfTokenRequestAttributeHandler {
        private final ServerCsrfTokenRequestAttributeHandler delegate = new XorServerCsrfTokenRequestAttributeHandler();

        @Override
        public void handle(ServerWebExchange exchange, Mono<CsrfToken> csrfToken) {
             // Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of the CsrfToken when it is rendered in the response body.
            this.delegate.handle(exchange, csrfToken);
        }

        @Override
        public Mono<String> resolveCsrfTokenValue(ServerWebExchange exchange, CsrfToken csrfToken) {
            final var hasHeader = exchange.getRequest().getHeaders().get(csrfToken.getHeaderName()) !=null;
            return hasHeader ? super.resolveCsrfTokenValue(exchange, csrfToken) : this.delegate.resolveCsrfTokenValue(exchange, csrfToken);
        }
    }

    @Bean
    //Needed in order to set the XSRF-TOKEN cookie
    //See https://docs.spring.io/spring-security/reference/reactive/integrations/cors.html
   public WebFilter csrfCookieWebFilter() {
        return (exchange, chain) -> {
            exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty()).subscribe(o -> ((CsrfToken)o).getToken());
            return chain.filter(exchange);
        };
    }


    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:63342"));
        configuration.setAllowedMethods(List.of(CorsConfiguration.ALL));
        configuration.setAllowCredentials(true);
        configuration.setAllowedHeaders(List.of(CorsConfiguration.ALL));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

Client

const stompClient = new StompJs.Client({
            brokerURL: 'ws://localhost:9990/notification/ws-connect',
            connectHeaders : {"X-XSRF-TOKEN":csrfToken}
        });

Error when passing the token generated by gateway

org.springframework.security.web.csrf.InvalidCsrfTokenException: Invalid CSRF Token 'd1f38e4e-71cc-4241-8c7e-3b01c1937c7c' was found on the request parameter '_csrf' or header 'X-XSRF-TOKEN'

I have uploaded a diagram, maybe this explains the situation better:

enter image description here

Thanks in advance ! Any help is appreciated !

Upvotes: 1

Views: 589

Answers (3)

IonutB
IonutB

Reputation: 85

I believe that in order to understand my answers, you need to understand the architecture I am trying to achieve, as this of course will impact how I think and what I expect (wrongly or not) from the framework itself. My intention is to have a bunch of microservices that are accessed via a gateway. From my knowledge, one of the things you implement in the gateway is security. CSRF is part of security so this should be implemented in the gateway too. The practice that I am aware of is to centralize security in the gateway, in order to have a single door to guard. I am expecting to be able to guard any endpoint, regardless if it is http or websocket. Based on this rationale here are my answers:

First, I don't see your sample using bearer tokens to make requests to the gateway, therefore you can remove http.oauth2ResourceServer() configuration in the gateway.

Please ignore as I will have an SPA with the gateway as the OAuth client. Hopefully, having the gateway as a resource server is not the root cause of my issue here. I will remove it and see what happens.

Since you aren't using bearer tokens for requests from the client (I'm assuming it will actually be a single-page app?) to the gateway, CSRF protection is required for HTTP POST requests. CSRF protection in this case is between the client and the gateway, and has nothing to do with notification-service.

I understand that CSRF is required for POST requests, but since notification-service is using spring-websockets, which requires CSRF token for websocket endpoint (https://docs.spring.io/spring-security/reference/servlet/integrations/websocket.html#websocket-sameorigin-csrf), shouldn't the gateway be also aware and involved in this?

As I have mentioned, configuring HTTP requests to the resource server (e.g. GET /ws-connect) to require only a bearer token does not affect websocket messages in any way. This is not a bug. The two have separate configuration in Spring Security.

Not sure I understand this one, since spring-security checks by default if there is a bearer token, and if one is found, it skips csrf token validation, as it considers that there cannot be a csrf attack if you have a token instead of session cookie. IMHO this should be the case for websockets also, as a csrf attack happens in the same fashion as http. Check this for an explanation on bearer token-csrf behavior for http

You have configured websockets with csrf protection on the resource server to use the CsrfChannelInterceptor (instead of XorCsrfChannelInterceptor) which effectively matches the configuration you use on the gateway with SpaServerCsrfTokenRequestHandler. This is correct, and is required because your SPA client is sending plain CSRF tokens instead of BREACH tokens (see CsrfTokenRequestAttributeHandler vs XorCsrfTokenRequestAttributeHandler).

Not sure what I am doing wrong here. The SpaServerCsrfTokenRequestHandler uses XorCsrfTokenRequestAttributeHandler as a delegate. By default, XorCsrfChannelInterceptor is used on the notification-server side. Shouldn't XorCsrfTokenRequestAttributeHandler work with XorCsrfChannelInterceptor? While debugging I saw 2 identical strings not being equal...

The websocket connect request is for notification-service, not the gateway. It's not clear to me why you expect anything different here.

Based on my rationale, the websocket connect request is for the gateway, as the SPA has no knowledge of any notfication-service, nor it should have. The whole point of the gateway is to be the entry point in the system and to implement routing, circuit-breaking and security to name a few. So gateway should receive the request, apply security(check for csrf token, etc) and route to notification-service if the request passes security checks.

All in all, your configuration seems mostly correct to me. It is however an advanced setup. We could potentially improve documentation (that's always a good option), but this setup is fairly specific so I'm not sure what that would look like.

I am not sure why this is an advanced/specific setup, as having security in the gateway is quite standard IMHO. Because of the websockets situation I am forced to have security configuration scattered around services and gateway, which does not seem like a good design. Gateway should be able to protect all type of endpoints, regardless of the endpoint type (http,websockets or anything else).

Upvotes: 0

Steve Riesenberg
Steve Riesenberg

Reputation: 6158

Thank you for providing a sample. It does clarify some things, but there are several aspects of your setup that require feedback and discussion.

First, I don't see your sample using bearer tokens to make requests to the gateway, therefore you can remove http.oauth2ResourceServer() configuration in the gateway.

Since you aren't using bearer tokens for requests from the client (I'm assuming it will actually be a single-page app?) to the gateway, CSRF protection is required for HTTP POST requests. CSRF protection in this case is between the client and the gateway, and has nothing to do with notification-service.

Second, the connect request is an HTTP GET, and does not require or support CSRF protection. However, the subsequent websocket handshake is between the client and the notification-service, with the gateway only proxying the request. The gateway's configuration regarding CSRF does not automatically impact this in any way. This is an odd setup because you are placing a websockets server in a resource server, and therefore requiring your client to have some knowledge of how your backend (behind the gateway) works. If you truly need to do this, you can make it transparent by matching CSRF configuration between the gateway (for HTTP requests) and the notification-service (for websocket messages), which you have done. Because this setup spans two systems, nothing will automatically match up the configuration for you.

Not sure is this was intended but is confusing. For http calls, Spring will skip CSRF validation if Bearer token is found, while for websockets it does not, even ig the initial connect message is a http call.

As I have mentioned, configuring HTTP requests to the resource server (e.g. GET /ws-connect) to require only a bearer token does not affect websocket messages in any way. This is not a bug. The two have separate configuration in Spring Security.

CSRF cookie and header do not match for websockets Connect endpoint, which was solved by using CsrfChannelInterceptor.

You have configured websockets with csrf protection on the resource server to use the CsrfChannelInterceptor (instead of XorCsrfChannelInterceptor) which effectively matches the configuration you use on the gateway with SpaServerCsrfTokenRequestHandler. This is correct, and is required because your SPA client is sending plain CSRF tokens instead of BREACH tokens (see CsrfTokenRequestAttributeHandler vs XorCsrfTokenRequestAttributeHandler).

The question on stackoverflow is "How to secure microservice websocket endpoint with Spring Cloud Gateway?". More specifically how to implement csrf for websocket connect endpoint in gateway. I have not found any way to do that. This example works, as I have added csrf for websockets in the downstream service, but this is not what I want.

The websocket connect request is for notification-service, not the gateway. It's not clear to me why you expect anything different here. Regardless, you cannot disable CSRF protection on the gateway or the websockets server because requests are coming from a browser. Since you are only proxying requests and messages, the gateway cannot improve the situation for you.

All in all, your configuration seems mostly correct to me. It is however an advanced setup. We could potentially improve documentation (that's always a good option), but this setup is fairly specific so I'm not sure what that would look like.

Upvotes: 1

asgarov1
asgarov1

Reputation: 4161

You can define to use CookieServerCsrfTokenRepository which means CSRF tokens won't be persisted on the Server and instead will be persisted in Cookies only:

@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
    http
    .csrf(csrf -> csrf.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse()))
    .build();
}

Then the front-end has to read the value from Cookie (XSRF-TOKEN) and send it with header X-XSRF-TOKEN. This would solve your problem. Backend will compare both and allow request if both are present and have matching values.

See Using the CookieCsrfTokenRepository

Upvotes: 1

Related Questions