spring-bootspring-securityspring-websocketspring-cloud-gatewaycsrf-token

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 !


Solution

  • 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.