reactjsspring-bootaxioscsrf

SpringBoot + React: SpringBoot not checking CSRF token


Environment

Frontend (FE) is React 18, backend (BE) is SpringBoot 3

Issue:

SpringBoot ignores XSRF-TOKEN header/cookie and returns HTTP 403.

  1. From React, I am able to ask via HTTP-GET and obtain csrf-token-cookie and store the value.
  2. Then, in React, I am able to construct HTTP-POST request with XSRF-TOKEN (or X-CSRF-TOKEN) header with token value.
  3. However, once submitted via HTTP-POST, the SpringBoot returns 403 regardless the headers.

Moreover, the returned error 403 contains no more info, no description. Server log is also empty.

Motivation

I have JWT as httpOnly cookie for user authorization. However, such cookie should be protected against CSRF. Therefore, I am trying to add csrf-token in local storage and send it via every non-GET request in a header.

MWE/Implementation

https://github.com/Engin1980/csrf-mwe

Controller (BE)

@RestController
public class AppController {

  @GetMapping
  public void get(){
    // blank
  }

  @PostMapping
  public void post(){
    // blank
  }
}

WebSecurity (BE)

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {
  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    CookieCsrfTokenRepository cookieCsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
    cookieCsrfTokenRepository.setCookieCustomizer(q -> q.sameSite("strict"));
    http.csrf(q -> q.csrfTokenRepository(cookieCsrfTokenRepository).csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()));
    http.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class);
    http.addFilterBefore(new CsrfCheckFilter(), CsrfFilter.class);

    http.cors(q -> q.disable());

    http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

    http.authorizeHttpRequests(auth -> auth.requestMatchers("/**").permitAll());

    return http.build();
  }
}

final class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler {
  private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler();

  @Override
  public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
    this.delegate.handle(request, response, csrfToken);
  }

  @Override
  public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
    if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) {
      return super.resolveCsrfTokenValue(request, csrfToken);
    }
    return this.delegate.resolveCsrfTokenValue(request, csrfToken);
  }
}

final class CsrfCookieFilter extends OncePerRequestFilter {

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
          throws ServletException, IOException {
    CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf");
    csrfToken.getToken();
    filterChain.doFilter(request, response);
  }
}

final class CsrfCheckFilter extends OncePerRequestFilter {
  private final Logger logger = LoggerFactory.getLogger(CsrfCheckFilter.class);

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
          throws ServletException, IOException {
    String key;
    key = "XSRF-TOKEN";
    logger.info("Header {}={}", key, request.getHeader(key));
    key = "X-CSRF-TOKEN";
    logger.info("Header {}={}", key, request.getHeader(key));

    filterChain.doFilter(request, response);
  }
}

React App Component (FE)

import React from "react";
import logo from "./logo.svg";
import "./App.css";
import axios from "axios";

function App() {
  axios.defaults.baseURL = "http://localhost:5555";
  axios.defaults.withCredentials = true;
  axios.interceptors.request.use((c) => {
    const csrf: string | null = localStorage.getItem("csrf");
    if (csrf) {
      console.log("inter.req setting csrf " + csrf);
      c.headers["XSRF-TOKEN"] = csrf;
      c.headers["X-CSRF-TOKEN"] = csrf;
    }
    return c;
  });

  const getClick = () => {
    axios
      .get("")
      .then((q) => {
        console.log("GET completed");
        console.log("Cookies: " + document.cookie);
        const csrf = document.cookie
          .split(";")
          .filter((q) => q.startsWith("XSRF-TOKEN"))?.[0]
          ?.split("=")?.[1];
        console.log("CSRF token: " + csrf);
        if (csrf) localStorage.setItem("csrf", csrf);
      })
      .catch((e) => {
        console.error("GET failed");
        console.error(e);
      });
  };

  const postClick = () => {
    axios.post("").then((q) => {
      console.log("POST completed");
    });
  };

  return (
    <div className="App">
      <h1>CSRF test</h1>
      <input type="button" value="GET" onClick={getClick} />
      <input type="button" value="POST" onClick={postClick} />
    </div>
  );
}

export default App;

Solution

  • From spring-security docs:

    The CookieCsrfTokenRepository writes to a cookie named XSRF-TOKEN and reads it from an HTTP request header named X-XSRF-TOKEN or the request parameter _csrf by default. These defaults come from Angular and its predecessor AngularJS.

    Hence, please try to change c.headers["XSRF-TOKEN"] = csrf; to c.headers["X-XSRF-TOKEN"] = csrf;