Keep Spring Security's CSRF protection on (it is the default) and never call csrf.disable() for browser-facing routes. Put the _csrf token in every form as a hidden field and send it from scripts in the X-CSRF-TOKEN header. Map state changes with @PostMapping, not a bare @RequestMapping, because the CSRF filter skips GET.
- Search the SecurityFilterChain beans for csrf(csrf -> csrf.disable()), csrf(AbstractHttpConfigurer::disable) and broad ignoringRequestMatchers(...), and remove them for routes that browsers reach with the session cookie.
- Render the token in every form that posts: Thymeleaf and Spring's form tag library add it automatically; otherwise write <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">, or take a CsrfToken parameter in the controller as this card does.
- For scripts, expose the token in a meta tag and send it in the X-CSRF-TOKEN header; a single-page app that reads it from a cookie should use csrf(csrf -> csrf.spa()) (Spring Security 7.0 and later).
- Replace @RequestMapping without a method on state-changing handlers with @PostMapping, @PutMapping, @PatchMapping or @DeleteMapping.
- Set server.servlet.session.cookie.same-site=lax and server.servlet.session.cookie.secure=true as defence in depth.
- Add regression tests that send the session cookie with the headers a browser sends for a cross-site form, a sibling subdomain, another user's token and a cross-site link. In MockMvc tests, .with(csrf()) supplies a valid token; prove the protection with requests that have no token or use csrf().useInvalidToken().
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
// No method attribute, so every method maps here: the form POSTs, and the link in the
// "confirm your new address" email sends GET /account/email?email=...
@RequestMapping("/account/email")
ResponseEntity<String> updateEmail(Principal user, @RequestParam String email) {
if (!email.matches("[^@\\s]+@[^@\\s]+")) {
return ResponseEntity.badRequest().body("Enter a valid email address");
}
accounts.changeEmail(user.getName(), email);
return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create("/account")).build();
}With IMPL=vulnerable, 7 of 12 tests fail (7 exploit tests).
The fix
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(requests -> requests.anyRequest().authenticated())
.formLogin(form -> form.defaultSuccessUrl("/account", true))
// On by default and shown for clarity: POST, PUT, PATCH and DELETE need the token
// stored in the session, as the _csrf parameter or the X-CSRF-TOKEN header.
.csrf(Customizer.withDefaults());
return http.build();
}With IMPL=fixed, 12 of 12 tests pass.
The fix: change state only on POST
@PostMapping("/account/email") // POST only, so a link cannot reach it
ResponseEntity<String> updateEmail(Principal user, @RequestParam String email) {
if (!email.matches("[^@\\s]+@[^@\\s]+")) {
return ResponseEntity.badRequest().body("Enter a valid email address");
}
accounts.changeEmail(user.getName(), email);
return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create("/account")).build();
}With IMPL=fixed, 12 of 12 tests pass.
A fix that looks safe but is not
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, AccountStore accounts) throws Exception {
String host = URI.create(accounts.origin()).getHost(); // "portal.example.test"
http
.authorizeHttpRequests(requests -> requests.anyRequest().authenticated())
.formLogin(form -> form.defaultSuccessUrl("/account", true))
.csrf(csrf -> csrf.disable()) // replaced by the Referer check
.addFilterBefore(new RefererCheck(host), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
static final class RefererCheck extends OncePerRequestFilter {
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE");
private final String host;
RefererCheck(String host) {
this.host = host;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String referer = request.getHeader("Referer");
// Some browsers and privacy tools strip the Referer, so allow requests without one.
if (SAFE_METHODS.contains(request.getMethod()) || referer == null || referer.contains(host)) {
chain.doFilter(request, response);
} else {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Cross-site request refused");
}
}
}With IMPL=wrong, 3 of 12 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/**
* Exploit: each forged request carries Alice's session cookie. Browsers withhold a
* SameSite=Lax cookie from cross-site POSTs, but not from sibling subdomains or top-level
* GET navigations, and older browsers ignore SameSite, so the server must refuse anyway.
*/
class ExploitTest extends BrowserTestSupport {
private Response forge(Browser browser, Map<String, String> headers, Map<String, String> fields) throws Exception {
return browser.post("/account/email", headers, form(fields, "email", "mallory@evil.example"));
}
@Test
void crossSiteFormPostWithoutATokenIsRefused() throws Exception {
Browser alice = signIn("alice");
assertRefused(forge(alice, formFrom(EVIL, "cross-site"), Map.of()));
}
@Test
void foreignOriginIsRefusedWhenTheBrowserSendsNoFetchMetadata() throws Exception {
Browser alice = signIn("alice");
assertRefused(forge(alice, Map.of("Origin", EVIL, "Referer", EVIL + "/"), Map.of()));
}
@Test
void siblingSubdomainIsRefusedAlthoughSameSiteCookiesReachIt() throws Exception {
Browser alice = signIn("alice");
assertRefused(forge(alice, formFrom(SIBLING, "same-site"), Map.of()));
}
@Test
void tokenFromTheAttackersOwnSessionIsRefused() throws Exception {
Map<String, String> bobsFields = hiddenFields(signIn("bob").accountPage());
Browser alice = signIn("alice");
assertRefused(forge(alice, formFrom(EVIL, "cross-site"), bobsFields));
}
@Test
void refererThatMerelyContainsTheHostNameIsRefused() throws Exception {
Browser alice = signIn("alice");
assertRefused(forge(alice, formFrom(LOOKALIKE, "cross-site"), Map.of()));
}
@Test
void pageThatSuppressesTheRefererIsRefused() throws Exception {
// <meta name="referrer" content="no-referrer"> on the attacking page: no Referer, Origin: null.
Browser alice = signIn("alice");
Map<String, String> headers = Map.of("Origin", "null", "Sec-Fetch-Site", "cross-site", "Sec-Fetch-Mode", "navigate", "Sec-Fetch-Dest", "document");
assertRefused(forge(alice, headers, Map.of()));
}
@Test
void crossSiteGetNavigationCannotChangeTheEmail() throws Exception {
// Lax cookies are sent on top-level GET navigations, so only the method protects this.
Browser alice = signIn("alice");
Response res = alice.get("/account/email?email=mallory%40evil.example", linkFrom(EVIL));
assertThat(accounts.emailOf("alice")).as("a GET changed Alice's email (HTTP %s)", res.status()).isEqualTo("alice@example.test");
assertThat(res.status()).isGreaterThanOrEqualTo(400);
}
}- csrf.disable() is the usual way this breaks: it gets added when form posts or tests start failing with 403. Fix the client instead.
- @RequestMapping without a method answers every HTTP method, and the CsrfFilter does not check GET, HEAD, TRACE or OPTIONS, so the handler can be driven with a plain link.
- Spring Security checks only the token, not Origin or Sec-Fetch-Site (7.1.1 has no Fetch Metadata support), so a token that leaks, for example through a URL or a log, is accepted whichever site the request comes from.
- The token is masked differently in every response (XorCsrfTokenRequestAttributeHandler, which protects against BREACH). A client that reads the raw token from the XSRF-TOKEN cookie needs csrf.spa() or a matching request handler.
- The token lives in the HttpSession by default: when the session expires, posts fail with 403 until the page is reloaded.
- With CSRF protection on, LogoutFilter only handles POST /logout; a GET logout link stops working by design.
- Sign-in is protected too: the generated /login form carries _csrf, and CsrfAuthenticationStrategy replaces the token after authentication, so pages must render a fresh token after sign-in.
- Semgrep's spring-csrf-disabled rule only knows the http.csrf().disable() chain that Spring Security 7 removed; use CodeQL java/spring-disabled-csrf-protection or a custom rule for the lambda form.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csrf/java-spring does this for you).
- Run semgrep --config r/java.spring.security.unrestricted-request-mapping.unrestricted-request-mapping src/main/java and grep for csrf.disable and AbstractHttpConfigurer::disable; expect findings only in VulnerableSecurity and WrongSecurity.
- Against your own app, replay a forged post with a signed-in user's JSESSIONID and no token, for example curl -i -b 'JSESSIONID=...' -H 'Origin: https://evil.example' --data 'email=mallory@evil.example' https://your.app/account/email, and expect 403 with no change; repeat as a GET and expect 405.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | java.spring.security.unrestricted-request-mapping.unrestricted-request-mapping | Flagged the @RequestMapping("/account/email") handler in VulnerableSecurity and in WrongSecurity; no findings in FixedSecurity, which uses @PostMapping.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | java.spring.security.audit.spring-csrf-disabled.spring-csrf-disabled | No findings, although VulnerableSecurity and WrongSecurity disable CSRF protection. The pattern $OBJ.csrf(...).disable(...) matches only the chained http.csrf().disable(), which Spring Security 7 removed (HttpSecurity 7.1.1 has only csrf(Customizer)). On a probe file it flagged the chained form and missed csrf(csrf -> csrf.disable()) and csrf(AbstractHttpConfigurer::disable).Checked: Semgrep 1.180.0 (CE) |
| CodeQL | java/spring-disabled-csrf-protection | Expected to flag VulnerableSecurity and WrongSecurity: java/ql/lib/semmle/code/java/security/SpringCsrfProtection.qll matches disable() called on CsrfConfigurer<HttpSecurity>, which covers the lambda form, and csrf(AbstractHttpConfigurer::disable).Checked: github/codeql main at b5b8165; not executed |
| CodeQL | java/csrf-unprotected-request-type | Expected to flag updateEmail in VulnerableSecurity and WrongSecurity: the query treats @RequestMapping without a method, and @GetMapping, as unprotected, and reports such handlers when they reach a database update or when their name contains a state-changing word such as update (CsrfUnprotectedRequestTypeQuery.qll). An in-memory change in a method named, say, changeEmail would not be reported.Checked: github/codeql main at b5b8165; not executed |
| Find Security Bugs (SpotBugs plugin) | SPRING_CSRF_PROTECTION_DISABLEDSpring CSRF protection disabled | Not run on this card.Checked: Bug pattern listed on the Find Security Bugs site; not executed |
| Find Security Bugs (SpotBugs plugin) | SPRING_CSRF_UNRESTRICTED_REQUEST_MAPPINGSpring CSRF unrestricted RequestMapping | Not run on this card.Checked: Bug pattern listed on the Find Security Bugs site; not executed |
| Component | Version |
|---|---|
| Language | Java 21 |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring Security 7.1.1, Spring Framework 7.0.9, Spring MVC) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework.boot:spring-boot-starter-security | 4.1.1 (Spring Security 7.1.1) |
| org.apache.tomcat.embed:tomcat-embed-core (overrides Boot's 11.0.24) | 11.0.26 |
| tools.jackson:jackson-bom (overrides Boot's 3.1.5) | 3.1.7 |
| org.springframework.boot:spring-boot-starter-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csrf/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs csrf/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- java OpenJDK 21.0.12.1 (Homebrew)
- maven 3.9.16
- springBoot 4.1.1
- springSecurity 7.1.1
- tomcat 11.0.26
- junitJupiter 6.0.3
- Matrix
- fixed: 12 of 12 tests pass; vulnerable: 7 of 12 tests fail (7 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
- Test results
- fixed 12/12 pass; vulnerable 7/12 fail (all 7 exploit tests); wrong 3/12 fail (3 exploit tests), offline after install
- Dependency freshness
- Spring Boot 4.1.1 and Spring Security 7.1.1 were published on 2026-08-20. Boot 4.1.1 manages Tomcat 11.0.24 and Jackson 3.1.5, which OSV lists with advisories fixed in Tomcat 11.0.26 and Jackson 3.1.7 (GHSA-9xv2-5v5q-p794, GHSA-gcx9-497g-6cp6, GHSA-h3x4-894j-xpx5; GHSA-cxp5-3px4-pw24 and others), so the pom overrides tomcat.version to 11.0.26 (2026-09-09) and jackson-bom.version to 3.1.7 (2026-09-21). OSV listed no advisories for the 74 resolved artifacts after the overrides. Maven has no lockfile; the Boot parent and the two overrides fix every version.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet
- Spring Security: Cross Site Request Forgery (CSRF) for servlet applications
- Spring Security: CSRF protection concepts (safe methods, when to use CSRF protection)
- Spring Security: form login
- Spring Boot: common application properties (server.servlet.session.cookie.*)
- Find Security Bugs: SPRING_CSRF_UNRESTRICTED_REQUEST_MAPPING
- MDN: Set-Cookie (SameSite)
Attack patterns: