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
@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
// 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
<!-- 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
ignoringRequestMatcherscovering a path authenticated by a cookie. - Not using
formLogin,rememberMe,httpBasic, oroauth2Loginalongside disabled CSRF — all of them are ambient. server.servlet.session.cookie.same-site: laxset explicitly. Spring has no default.- Session cookie
secure: true,http-only: true. - With
CookieCsrfTokenRepository:domain(null)so a sibling subdomain cannot overwrite it, andXorCsrfTokenRequestAttributeHandlerfor BREACH. - An
OriginorSec-Fetch-Sitecheck added — Spring provides none. @RequestMappingdeclares an explicitmethod; without one it maps every verb. See Method and Content-Type Switching.@RequestBodyendpoints declareconsumes = "application/json"so a form-encoded body is rejected.- Session fixation protection is at its default (
migrateSession) and not set tonone.
Related
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.
Naive double-submit and why OWASP now discourages it, then the HMAC-signed and encrypted-token variants that fix it.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
Overwriting the CSRF cookie from a sibling subdomain, why SameSite offers zero protection here, and why __Host- is the fix.
GET and POST routing, _method and X-HTTP-Method-Override, and body parsers that ignore the declared content type.
Forcing the victim into the attacker's session, the OAuth state parameter, callback CSRF, and account-linking takeover.