1
0

Add the cors-csrf module

This commit is contained in:
2026-08-28 09:33:22 +05:30
parent 73ab67b171
commit cad813e1ae
49 changed files with 3338 additions and 13 deletions

View File

@@ -0,0 +1,79 @@
# 5. The `/error` dispatch, or why your 403 arrives as a 401
*Prev: [4. Preflight handlers](04-preflight-handlers.md) · Next: [6. CSRF for SPAs](06-csrf-for-spas.md)*
Two of the failures in this module — the wildcard/credentials clash in chapter 4 and the
CSRF rejection in chapter 6 — produce a status code that has nothing to do with what went
wrong. Both have the same cause, and it is worth understanding once because it explains a large
fraction of confusing Spring Security bug reports.
## The mechanism
1. Something inside the chain calls `response.sendError(403, ..)` (that is what
`AccessDeniedHandlerImpl` does) or lets an exception escape `FilterChainProxy`.
2. The servlet container does not write that response. It **re-dispatches** the request
internally to `/error`, with `DispatcherType.ERROR`.
3. Spring Boot registers `springSecurityFilterChain` for **every** dispatcher type:
`SecurityFilterProperties.dispatcherTypes` defaults to `EnumSet.allOf(DispatcherType.class)`.
So the whole security chain runs again on that dispatch.
4. On the second pass, the filters that extend `OncePerRequestFilter` skip themselves —
`shouldNotFilterErrorDispatch()` defaults to `true`. `BasicAuthenticationFilter` is one of
them. The credential is never re-read.
5. The filters that extend `GenericFilterBean` do run. `AuthorizationFilter` is one of them.
6. So the second pass is **authorized but not authenticated**: `AuthorizationFilter` evaluates
`/error` against `anyRequest().authenticated()`, finds an anonymous principal, and denies it.
7. The 401 from step 6 is what reaches the client. The 403 from step 1 is gone.
The mechanism is set out in full in
[The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/);
this chapter is what it looks like when it lands on a CORS or CSRF problem.
## Proving it in one diff
The `errorpermit` profile adds one filter chain, `@Order(0)`, matching `/error` and permitting
everything. Nothing else changes.
```
./scripts/run.sh csrfnaive POST → 401, empty body, WWW-Authenticate: Basic
./scripts/run.sh csrfnaive,errorpermit POST → 403, {"status":403,"error":"Forbidden", ...}
```
[`docs/output/08-csrf-naive.txt`](output/08-csrf-naive.txt) against
[`docs/output/09-error-dispatch.txt`](output/09-error-dispatch.txt).
## What to do about it
Permit `/error`. It is not a hole: the error page is generated from an attribute the container
set, and an unauthenticated request cannot reach it except through a dispatch the container
initiated.
```java
@Bean
@Order(0)
SecurityFilterChain errorChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/error")
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.csrf(CsrfConfigurer::disable)
.build();
}
```
The alternative is to narrow the dispatcher types so the chain does not run on the error
dispatch at all:
```yaml
spring.security.filter.dispatcher-types: request
```
That one is broader in effect than it looks; prefer the `/error` chain unless you have a
specific reason.
## Why this matters more for a SPA than for a server-rendered app
A browser will not let a SPA read a cross-origin response unless the CORS headers are present.
When the 403 is replaced by a 401 written on a dispatch where `CorsFilter` may or may not have
re-run, what the developer sees in the console is neither "403" nor "CSRF"; it is
`TypeError: Failed to fetch`. Every layer of the stack has thrown away the actual cause by then.
---
*Prev: [4. Preflight handlers](04-preflight-handlers.md) · Next: [6. CSRF for SPAs](06-csrf-for-spas.md)*