3.3 KiB
1. Two layers, one word
Next: 2. Who resolves the CorsConfigurationSource
A Spring Boot application can be told about CORS in two entirely separate places, and the two places do not talk to each other unless you make them.
The MVC layer. WebMvcConfigurer.addCorsMappings(..) and @CrossOrigin register a
CorsConfiguration with Spring MVC's handler mappings. It is consulted inside
DispatcherServlet, when the request is being matched to a handler method.
The security layer. HttpSecurity.cors(..) puts a org.springframework.web.filter.CorsFilter
into the security filter chain. It runs at order 1000 — between HeaderWriterFilter
(900) and CsrfFilter (1100), and a long way above AuthorizationFilter (4200).
The security filter chain runs to completion before DispatcherServlet is ever entered. So if
the security chain rejects a request, MVC's CORS configuration is not merely ignored: the code
that reads it never executes.
Why that specifically breaks preflights
A CORS preflight is not a special protocol. It is an ordinary OPTIONS request carrying two
headers:
OPTIONS /api/data HTTP/1.1
Origin: https://spa.example.com
Access-Control-Request-Method: POST
The browser sends it without credentials — no cookies, no Authorization header, by
design. Against anyRequest().authenticated() that request is anonymous, and anonymous requests
are denied. The rejection happens at order 4200, three thousand two hundred slots before
DispatcherServlet and about four thousand before your addCorsMappings call matters.
docs/output/01-mvc-only.txt is that state: an eleven-filter chain
with no CorsFilter in it, and a preflight answered 401.
The fix is one line, and it is not on the MVC layer
http.cors(Customizer.withDefaults())
That is MvcBridgeSecurityConfig,
and docs/output/02-mvc-bridge.txt is the identical application
answering 200. The MVC configuration was fine all along. Nothing was reading it.
CorsFilter short-circuits every preflight it sees:
boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
if (!isValid || CorsUtils.isPreFlightRequest(request)) {
return; // the chain stops here
}
filterChain.doFilter(request, response);
Read that if carefully, because chapter 2 turns on it: the filter returns on every
preflight, whether or not it found a configuration to apply.
One thing that changes when you move the configuration
Moving CORS from addCorsMappings to a CorsConfigurationSource bean is not a pure
relocation. Compare the two transcripts:
02-mvc-bridge.txt Access-Control-Max-Age: 1800
03-security-source.txt (nothing)
CorsRegistration — the builder behind addCorsMappings — defaults maxAge to
1800 seconds. A bare CorsConfiguration leaves it null, and a preflight response with no
Access-Control-Max-Age is not cached, so the browser preflights every single cross-origin
call. Two round trips instead of one, forever, with nothing in any log to suggest it.
configuration.setMaxAge(1800L);