83 lines
3.3 KiB
Markdown
83 lines
3.3 KiB
Markdown
# 1. Two layers, one word
|
|
|
|
*Next: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md)*
|
|
|
|
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`](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
|
|
|
|
```java
|
|
http.cors(Customizer.withDefaults())
|
|
```
|
|
|
|
That is [`MvcBridgeSecurityConfig`](../src/main/java/com/ankurm/cors/config/MvcBridgeSecurityConfig.java),
|
|
and [`docs/output/02-mvc-bridge.txt`](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:
|
|
|
|
```java
|
|
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.
|
|
|
|
```java
|
|
configuration.setMaxAge(1800L);
|
|
```
|
|
|
|
---
|
|
*Next: [2. Who resolves the CorsConfigurationSource](02-who-resolves-the-source.md)*
|