Skip to content
CWE-352A01:2021 – Broken Access Control

Spring Security

Why .csrf().disable() is the most-copied line in Java web security, CookieCsrfTokenRepository, and the missing Origin check.

The rule

Never call .csrf(csrf -> csrf.disable()) on an application that authenticates with a cookie or an HTTP session.

Spring Security enables CsrfFilter by default and it works correctly. Nearly every Spring CSRF finding traces to that one line, copied from a tutorial about stateless JWT APIs into an application that is not stateless.

The reasoning in those tutorials is sound for their context: if the credential is a bearer token in an Authorization header, it is not ambient, so there is nothing to forge and CSRF protection adds friction for no benefit. The line becomes a vulnerability when the application also — or instead — uses JSESSIONID, a remember-me cookie, or a JWT stored in a cookie.

Spring's other notable gap: it does not check Origin or Referer at all. Django and Rails both do. If you want a header check in Spring you must add it yourself, which is worth doing — see the Fetch Metadata filter below.

The disable trap

JavaSecurityConfig.javaVulnerable
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // The most-copied line in Java web security. Correct ONLY if every
            // credential is non-ambient -- a bearer token in a header.
            .csrf(csrf -> csrf.disable())

            // ...but this application uses form login, which means
            // JSESSIONID, which is a cookie, which is ambient. Every
            // state-changing endpoint below is now forgeable.
            .formLogin(Customizer.withDefaults())
            .rememberMe(Customizer.withDefaults())   // another ambient cookie

            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated());

        return http.build();
    }
}

// The variant that looks safer and is not:
//
//   .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
//
// If /api/** is authenticated by JSESSIONID, this exempts the entire API
// from protection. Exempt a path only when that path's credential is
// non-ambient.

Adding the header check Spring lacks

JavaFetchMetadataFilter.javaSecure
// Spring Security does not validate Origin or Referer. This filter adds a
// Resource Isolation Policy using Fetch Metadata, which is cheaper and more
// reliable than parsing Referer.
//
// Register it before CsrfFilter so a cross-site request is rejected before
// any token work happens.
public class FetchMetadataFilter extends OncePerRequestFilter {

    private static final Set<String> SAFE_METHODS =
            Set.of("GET", "HEAD", "OPTIONS");

    private static final Set<String> ALLOWED_SITES =
            Set.of("same-origin", "same-site", "none");

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain)
            throws ServletException, IOException {

        String site = req.getHeader("Sec-Fetch-Site");

        // Absent header: an old browser or a non-browser client. Allowing it
        // keeps those working and is why this must sit ALONGSIDE the token
        // rather than replacing it. Reject instead if every client is a
        // modern browser.
        if (site == null
                || ALLOWED_SITES.contains(site)
                || SAFE_METHODS.contains(req.getMethod())) {
            chain.doFilter(req, res);
            return;
        }

        res.sendError(HttpServletResponse.SC_FORBIDDEN,
                      "cross-site request rejected");
    }
}

// Note on "same-site": it means a subdomain. Remove it from ALLOWED_SITES if
// any subdomain hosts user content or could be taken over.

Templates and clients

HTMLaccount.htmlSecure
<!-- Thymeleaf injects the hidden _csrf input automatically into any form
     using th:action. A plain action= attribute does NOT get it, which is the
     usual cause of a 403 appearing on one form out of many. -->
<form th:action="@{/account/email}" method="post">
  <input type="email" name="email" required />
  <button type="submit">Update</button>
</form>

<!-- For fetch/XHR, render the token into meta tags: -->
<meta name="_csrf" th:content="${_csrf.token}" />
<meta name="_csrf_header" th:content="${_csrf.headerName}" />

Checklist

  • grep -rn 'csrf().disable()\|csrf(csrf -> csrf.disable())' returns nothing, unless every credential in the application is non-ambient.
  • No ignoringRequestMatchers covering a path authenticated by a cookie.
  • Not using formLogin, rememberMe, httpBasic, or oauth2Login alongside disabled CSRF — all of them are ambient.
  • server.servlet.session.cookie.same-site: lax set explicitly. Spring has no default.
  • Session cookie secure: true, http-only: true.
  • With CookieCsrfTokenRepository: domain(null) so a sibling subdomain cannot overwrite it, and XorCsrfTokenRequestAttributeHandler for BREACH.
  • An Origin or Sec-Fetch-Site check added — Spring provides none.
  • @RequestMapping declares an explicit method; without one it maps every verb. See Method and Content-Type Switching.
  • @RequestBody endpoints declare consumes = "application/json" so a form-encoded body is rejected.
  • Session fixation protection is at its default (migrateSession) and not set to none.