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,24 @@
package com.ankurm.cors;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Companion application for
* <a href="https://ankurm.com/spring-boot-4-cors-csrf-samesite/">CORS, CSRF and SameSite in
* Spring Boot 4</a>.
*
* <p>Every scenario in the article is a Spring profile on this one application. Start it with
* {@code ./scripts/run.sh <profile>} and drive it with {@code curl}; nothing here needs a
* browser, because a preflight request is just an {@code OPTIONS} with two headers.
*
* <p>See <a href="../../../../docs/01-two-layers.md">docs/01-two-layers.md</a> for why the same
* CORS configuration behaves differently depending on which layer you put it on.
*/
@SpringBootApplication
public class CorsCsrfApplication {
public static void main(String[] args) {
SpringApplication.run(CorsCsrfApplication.class, args);
}
}

View File

@@ -0,0 +1,58 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code csrfnaive}: {@code CookieCsrfTokenRepository.withHttpOnlyFalse()} on its own.
*
* <p>This is the recipe in every SPA tutorial written before Spring Security 6, and since 6.0
* it produces a 403 on the first POST. The default {@code CsrfTokenRequestHandler} is
* {@code XorCsrfTokenRequestAttributeHandler}: the value written into the {@code XSRF-TOKEN}
* cookie is XOR-masked against a per-response random, so the raw cookie value the SPA reads and
* echoes back in {@code X-XSRF-TOKEN} is not the value the server compares against.
*
* <p>Two more things go wrong here and both are visible in the transcripts:
* the token is <em>deferred</em>, so a plain {@code GET} does not set the cookie at all unless
* something dereferences the token; and the cookie carries no {@code SameSite} attribute, which
* browsers treat as {@code Lax}, so a cross-site SPA never receives it. See
* <a href="../../../../docs/06-csrf-for-spas.md">docs/06-csrf-for-spas.md</a>.
*/
@Configuration
@Profile("csrfnaive")
public class CsrfNaiveConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.build();
}
}

View File

@@ -0,0 +1,59 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code csrfspa}: {@code csrf.spa()}, added in Spring Security 7.0.
*
* <p>Disassembling {@code CsrfConfigurer.spa()} shows exactly two assignments: the repository
* becomes {@code CookieCsrfTokenRepository.withHttpOnlyFalse()} and the request handler becomes
* the package-private {@code SpaCsrfTokenRequestHandler}. That handler holds two delegates
* &mdash; a plain {@code CsrfTokenRequestAttributeHandler} with
* {@code setCsrfRequestAttributeName(null)}, and an {@code XorCsrfTokenRequestAttributeHandler}
* &mdash; writes with the XOR one and, on resolve, uses the plain one whenever the request
* carries the header. Header-carrying SPA requests compare raw values; form posts keep the
* BREACH masking.
*
* <p>Because {@code spa()} assigns both fields unconditionally, calling
* {@code csrfTokenRepository(..)} before it is silently discarded. See
* <a href="../../../../docs/06-csrf-for-spas.md">docs/06-csrf-for-spas.md</a> and
* {@link CsrfSpaOrderConfig}.
*/
@Configuration
@Profile("csrfspa")
public class CsrfSpaConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.spa())
.build();
}
}

View File

@@ -0,0 +1,69 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code crosssite}: what {@code spa()} still does not do for a genuinely cross-site
* SPA, and the two cookie attributes you have to add yourself.
*
* <p>{@code spa()} leaves the {@code XSRF-TOKEN} cookie with no {@code SameSite} attribute
* &mdash; {@code CookieCsrfTokenRepository}'s default cookie customizer is an empty lambda,
* confirmed in the bytecode. A cookie with no {@code SameSite} is treated as {@code Lax}, so
* it is not sent on a cross-site {@code fetch}. Setting {@code SameSite=None} without
* {@code Secure} does not help either: the browser rejects the whole {@code Set-Cookie}
* (RFC 6265bis &sect;5.5). Both attributes are required, together.
*
* <p>The same applies to the session cookie, which is Boot's concern rather than Spring
* Security's &mdash; see {@code application.yml} and
* <a href="../../../../docs/07-samesite.md">docs/07-samesite.md</a>.
*
* <p>Run with {@code -DOMIT_SECURE=true} to emit {@code SameSite=None} <em>without</em>
* {@code Secure} and watch {@code SpecCookieJar} reject it.
*/
@Configuration
@Profile("crosssite")
public class CsrfSpaCrossSiteConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
boolean omitSecure = Boolean.getBoolean("OMIT_SECURE");
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookieCustomizer((cookie) -> {
cookie.sameSite("None");
// The point of the flag: SameSite=None and Secure are a pair. Emitting one
// without the other produces a Set-Cookie that every browser discards.
cookie.secure(!omitSecure);
});
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.spa().csrfTokenRepository(repository))
.build();
}
}

View File

@@ -0,0 +1,60 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code spaorder}: {@code csrfTokenRepository(..)} placed <em>before</em>
* {@code spa()}, which throws it away.
*
* <p>{@code spa()} is not a "defaults if unset" method. Its two statements are unconditional
* field assignments, so the custom cookie name below never reaches the running application and
* the SPA gets a 403 while looking at a configuration that appears to say otherwise. Swap the
* two calls and it works. {@link CsrfSpaCrossSiteConfig} relies on that ordering.
*
* <p>This is <a href="https://github.com/spring-projects/spring-security/issues/18718">
* spring-security#18718</a>, and the surprising part is that the fix is a reordering rather
* than a different API.
*/
@Configuration
@Profile("spaorder")
public class CsrfSpaOrderConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN", "X-CSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookieName("MY-CSRF");
repository.setHeaderName("X-CSRF-TOKEN");
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
// Deliberately the wrong way round.
.csrf((csrf) -> csrf.csrfTokenRepository(repository).spa())
.build();
}
}

View File

@@ -0,0 +1,39 @@
package com.ankurm.cors.config;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Add-on profile {@code errorpermit}: a first filter chain that matches {@code /error} and
* permits everything, so the container's error dispatch stops rewriting the status code.
*
* <p>Without it, a rejection raised inside the chain calls {@code response.sendError(403, ..)},
* Tomcat re-dispatches the request to {@code /error}, and the security chain runs a second time
* on that dispatch. {@code BasicAuthenticationFilter} extends {@code OncePerRequestFilter} and
* skips error dispatches, so the credential is never re-read and the second pass is anonymous.
* {@code AuthorizationFilter} then denies it and the client receives <b>401</b> &mdash; the
* original 403 is gone.
*
* <p>Combine it with any other profile: {@code ./scripts/run.sh csrfnaive,errorpermit}.
* See <a href="../../../../docs/05-the-error-dispatch.md">docs/05-the-error-dispatch.md</a>, and
* <a href="https://ankurm.com/spring-security-filter-chain-explained/">The Spring Security
* Filter Chain Explained</a> for the mechanism in full.
*/
@Configuration
@Profile("errorpermit")
public class ErrorDispatchConfig {
@Bean
@Order(0)
SecurityFilterChain errorChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/error")
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,56 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code misnamed}: the same bean as {@link SecuritySourceConfig}, under a different
* name. This is the gap between the two lookups.
*
* <p>{@code HttpSecurityConfiguration.applyCorsIfAvailable} asks
* {@code getBeanNamesForType(UrlBasedCorsConfigurationSource.class)} and enables the CORS
* configurer if the array is non-empty &mdash; so the bean below <em>does</em> switch CORS on.
* {@code CorsConfigurer.getCorsConfigurationSource} then asks
* {@code containsBeanDefinition("corsConfigurationSource")}, which is false, and falls through
* to Spring MVC's registrations. There are none, so startup fails with
* {@code NoSuchBeanDefinitionException}.
*
* <p>The message it prints names three fixes and does not mention the one that applies:
* rename your bean.
*/
@Configuration
@Profile("misnamed")
public class MisnamedSourceConfig {
@Bean
UrlBasedCorsConfigurationSource apiCorsSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,40 @@
package com.ankurm.cors.config;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Profile {@code mvcbridge}: the same MVC CORS mapping as {@code mvconly}, plus one line.
*
* <p>{@code .cors(Customizer.withDefaults())} makes {@code CorsConfigurer} run. With no bean
* named {@code corsConfigurationSource} in the context it falls back to
* {@code CorsConfigurer.MvcCorsFilter.getMvcCorsConfigurationSource(..)}, which reads the
* registrations made by {@link MvcCorsConfig}. The resulting {@code CorsFilter} goes into the
* chain at order 1000 &mdash; before {@code CsrfFilter} (1100) and a long way before
* {@code AuthorizationFilter} (4200) &mdash; and short-circuits the preflight.
*
* <p>So MVC CORS configuration <em>can</em> drive the security layer. It just does not do so by
* itself.
*/
@Configuration
@Profile("mvcbridge")
public class MvcBridgeSecurityConfig {
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.cors(Customizer.withDefaults())
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,34 @@
package com.ankurm.cors.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* CORS configured on the MVC layer &mdash; the first thing everybody tries, and the thing that
* does not fix a preflight rejection on its own.
*
* <p>This registers a {@code CorsConfiguration} with Spring MVC's
* {@code AbstractHandlerMapping}. It is consulted inside {@code DispatcherServlet}, which is
* downstream of the entire security filter chain. If the preflight never reaches the servlet,
* this configuration never runs. See
* <a href="../../../../docs/01-two-layers.md">docs/01-two-layers.md</a>.
*
* <p>Active under the {@code mvconly} and {@code mvcbridge} profiles. The two profiles share
* this file and differ only in whether the security chain enables CORS &mdash; which is the
* whole point.
*/
@Configuration
@Profile({ "mvconly", "mvcbridge" })
public class MvcCorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://spa.example.com")
.allowedMethods("GET", "POST")
.allowedHeaders("Content-Type", "X-XSRF-TOKEN")
.allowCredentials(true);
}
}

View File

@@ -0,0 +1,38 @@
package com.ankurm.cors.config;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Profile {@code mvconly}: MVC has a CORS mapping, the security chain does not.
*
* <p>There is no {@code UrlBasedCorsConfigurationSource} bean here, so Spring Security's
* {@code HttpSecurityConfiguration.applyCorsIfAvailable} does not switch CORS on, so no
* {@code CorsFilter} enters the chain. The preflight {@code OPTIONS} therefore travels the
* whole chain and is judged by {@code AuthorizationFilter} at order 4200, which sees an
* anonymous request and rejects it. The browser reports a CORS error; the server log shows an
* authentication failure. Those are the same event.
*
* <p>Reproduce: {@code ./scripts/scenario-cors.sh mvconly}.
*/
@Configuration
@Profile("mvconly")
public class MvcOnlySecurityConfig {
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code securitysource}: the configuration that actually works, and the name that
* makes it work.
*
* <p>The bean method is called {@code corsConfigurationSource} on purpose. That literal string
* appears in {@code CorsConfigurer.getCorsConfigurationSource(..)} as a
* {@code containsBeanDefinition} check. Rename this method and the behaviour changes &mdash;
* see {@link MisnamedSourceConfig}.
*
* <p>Note also that {@code .cors(..)} is never called below. It does not need to be: with a
* {@code UrlBasedCorsConfigurationSource} bean present, {@code HttpSecurityConfiguration}
* applies the CORS configurer for you.
*/
@Configuration
@Profile("securitysource")
public class SecuritySourceConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,65 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code twosources}: two {@code UrlBasedCorsConfigurationSource} beans, one of which
* carries the magic name.
*
* <p>The reference documentation says that with more than one such bean "Spring Security won't
* automatically configure CORS support for you, because it cannot decide which one to use".
* In 7.1.1 that is not what the bytecode does: {@code applyCorsIfAvailable} tests
* {@code getBeanNamesForType(..).length} with {@code ifle}, i.e. "greater than zero", not
* "exactly one". CORS is applied, and the bean named {@code corsConfigurationSource} wins.
* {@code adminCorsSource} is never consulted on this chain.
*
* <p>Verified by {@code /diag/cors-sources} plus the preflight transcripts in
* <a href="../../../../docs/output/">docs/output/</a>.
*/
@Configuration
@Profile("twosources")
public class TwoSourcesConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
return sourceFor("https://spa.example.com");
}
@Bean
UrlBasedCorsConfigurationSource adminCorsSource() {
return sourceFor("https://admin.example.com");
}
private static UrlBasedCorsConfigurationSource sourceFor(String origin) {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(origin));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,18 @@
package com.ankurm.cors.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
/** One user, {@code alice}/{@code password}, shared by every profile. */
@Configuration
public class Users {
@Bean
UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withUsername("alice").password("{noop}password").roles("USER").build());
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.cors.config;
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Profile {@code wildcard}: {@code allowedOrigins("*")} together with
* {@code allowCredentials(true)}.
*
* <p>This is the combination every "just make CORS work" answer suggests, and it is illegal
* under the Fetch standard: a response may not carry both
* {@code Access-Control-Allow-Origin: *} and {@code Access-Control-Allow-Credentials: true}.
* Spring does not reject it at startup. It rejects it on the first preflight, from inside
* {@code CorsConfiguration.checkOrigin}, which means the failure surfaces as a 500 on an
* {@code OPTIONS} request rather than as a configuration error.
*
* <p>The fix is {@code setAllowedOriginPatterns(..)}, which echoes the request origin back
* instead of a literal asterisk.
*/
@Configuration
@Profile("wildcard")
public class WildcardCredentialsConfig {
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
@Order(1)
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/diag/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,46 @@
package com.ankurm.cors.spec;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Runs the {@code Set-Cookie} headers this application actually emits through
* {@link SpecCookieJar} and reports what a browser would do with them.
*
* <p>Pass real headers with repeated {@code ?h=} parameters &mdash;
* {@code scripts/scenario-samesite.sh} collects them from a live response and feeds them back
* in, so the input is never typed by hand.
*/
@RestController
public class CookieSpecReport {
@GetMapping("/diag/cookie-spec")
public Map<String, Object> report(@RequestParam("h") List<String> headers,
@RequestParam(name = "secure", defaultValue = "false") boolean secureContext) {
SpecCookieJar jar = new SpecCookieJar();
Map<String, String> outcomes = new LinkedHashMap<>();
for (String header : headers) {
String rejection = jar.setCookie(header, secureContext);
outcomes.put(header, (rejection == null) ? "stored" : rejection);
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("origin", secureContext ? "trustworthy (https, or http://localhost)" : "not trustworthy (plain http)");
out.put("setCookieOutcomes", outcomes);
out.put("sentOnSameSiteRequest",
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.SAME_SITE, true)));
out.put("sentOnCrossSiteTopLevelNavigation",
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_TOP_LEVEL_NAVIGATION, true)));
out.put("sentOnCrossSiteFetch",
orNone(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)));
return out;
}
private static String orNone(String header) {
return header.isEmpty() ? "(no cookies sent)" : header;
}
}

View File

@@ -0,0 +1,144 @@
package com.ankurm.cors.spec;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* A deliberately small cookie jar that applies the storage and sending rules a browser applies,
* so that "the browser drops this cookie" becomes something you can run instead of something
* you have to believe.
*
* <p>It is a model of two paragraphs of
* <a href="https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis">RFC 6265bis</a>,
* not a browser:
*
* <ul>
* <li><b>&sect;5.5 storage.</b> "If the cookie's {@code same-site-flag} is {@code None} and the
* cookie's {@code secure-only-flag} is false, then abort these steps and ignore the newly
* created cookie entirely." A {@code Set-Cookie} with {@code SameSite=None} and no
* {@code Secure} is not stored, and there is no error anywhere &mdash; the cookie simply never
* exists.</li>
* <li><b>&sect;5.8.3 sending.</b> A cookie whose {@code same-site-flag} is {@code Strict} or
* {@code Lax} is not attached to a cross-site request; {@code Lax} makes an exception for
* top-level safe-method navigations, which a {@code fetch()} from a SPA is not. A cookie with
* no {@code SameSite} attribute is treated as {@code Lax} &mdash; by Chromium-based browsers.
* Firefox has not enabled Lax-by-default on its release channel, so it still treats an absent
* attribute as unrestricted. This jar models the Chromium behaviour, because that is the one
* a deployment has to survive.</li>
* </ul>
*
* <p>Feeding the real {@code Set-Cookie} headers the application emits through this jar is what
* turns the SameSite section of the article into evidence. See
* <a href="../../../../docs/07-samesite.md">docs/07-samesite.md</a>.
*/
public final class SpecCookieJar {
/** How the request was initiated, which is what decides the same-site check. */
public enum Context {
/** Same registrable domain as the cookie's origin. */
SAME_SITE,
/** A top-level navigation (clicking a link, a form GET) from another site. */
CROSS_SITE_TOP_LEVEL_NAVIGATION,
/** An XHR/fetch/subresource load from another site. This is the SPA case. */
CROSS_SITE_SUBRESOURCE
}
/** A stored cookie, after the attributes have been parsed. */
public record StoredCookie(String name, String value, String sameSite, boolean secure,
boolean httpOnly, boolean partitioned) {
}
private final Map<String, StoredCookie> jar = new LinkedHashMap<>();
private final List<String> rejections = new ArrayList<>();
/**
* Apply one {@code Set-Cookie} header. Returns the reason it was rejected, or {@code null}
* when it was stored.
*/
public String setCookie(String header, boolean secureContext) {
String[] parts = header.split(";");
String[] nv = parts[0].split("=", 2);
String name = nv[0].trim();
String value = nv.length > 1 ? nv[1].trim() : "";
String sameSite = null;
boolean secure = false;
boolean httpOnly = false;
boolean partitioned = false;
for (int i = 1; i < parts.length; i++) {
String attribute = parts[i].trim();
String lower = attribute.toLowerCase(Locale.ROOT);
if (lower.startsWith("samesite=")) {
sameSite = attribute.substring("samesite=".length()).trim();
}
else if (lower.equals("secure")) {
secure = true;
}
else if (lower.equals("httponly")) {
httpOnly = true;
}
else if (lower.equals("partitioned")) {
partitioned = true;
}
}
// RFC 6265bis 5.5: the Secure attribute is only honoured from a trustworthy origin.
// HTTPS qualifies; so does http://localhost in every current browser, which is why
// this parameter is called secureContext rather than https.
boolean secureHonoured = secure && secureContext;
// RFC 6265bis 5.5: SameSite=None without an effective Secure is ignored entirely.
if ("None".equalsIgnoreCase(sameSite) && !secureHonoured) {
String reason = "REJECTED " + name + ": SameSite=None " + (secure
? "with Secure, but the origin is not trustworthy so Secure is not honoured"
: "and no Secure attribute") + " - RFC 6265bis 5.5";
this.rejections.add(reason);
return reason;
}
secure = secureHonoured;
// Partitioned (CHIPS) requires Secure as well.
if (partitioned && !secure) {
String reason = "REJECTED " + name + ": Partitioned without Secure";
this.rejections.add(reason);
return reason;
}
this.jar.put(name, new StoredCookie(name, value, sameSite, secure, httpOnly, partitioned));
return null;
}
/** The {@code Cookie} header a browser would send for a request made in this context. */
public String cookieHeaderFor(Context context, boolean safeMethod) {
StringBuilder sb = new StringBuilder();
for (StoredCookie cookie : this.jar.values()) {
if (!willSend(cookie, context, safeMethod)) {
continue;
}
sb.append(sb.isEmpty() ? "" : "; ").append(cookie.name()).append('=').append(cookie.value());
}
return sb.toString();
}
private static boolean willSend(StoredCookie cookie, Context context, boolean safeMethod) {
// No SameSite attribute means Lax in Chromium-based browsers, which is where the modern
// default bites. Firefox's release channel still treats an absent attribute as
// unrestricted; modelling the stricter of the two is the useful choice.
String effective = (cookie.sameSite() == null) ? "Lax" : cookie.sameSite();
return switch (context) {
case SAME_SITE -> true;
case CROSS_SITE_TOP_LEVEL_NAVIGATION ->
effective.equalsIgnoreCase("None") || (effective.equalsIgnoreCase("Lax") && safeMethod);
case CROSS_SITE_SUBRESOURCE -> effective.equalsIgnoreCase("None");
};
}
public List<String> rejections() {
return List.copyOf(this.rejections);
}
public Map<String, StoredCookie> stored() {
return Map.copyOf(this.jar);
}
}

View File

@@ -0,0 +1,67 @@
package com.ankurm.cors.web;
import java.util.LinkedHashMap;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* The API the imaginary single-page application talks to.
*
* <p>{@code /api/boom} exists to demonstrate one specific failure: a request that passed the
* CORS check and then threw. Tomcat re-dispatches to {@code /error}, and what the browser
* reports is not the 500 &mdash; see
* <a href="../../../../docs/05-the-error-dispatch.md">docs/05-the-error-dispatch.md</a>.
*/
@RestController
public class ApiController {
@GetMapping("/api/data")
public Map<String, Object> data(HttpServletRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("method", request.getMethod());
body.put("origin", String.valueOf(request.getHeader("Origin")));
body.put("cookies", cookieNames(request));
return body;
}
@PostMapping("/api/data")
public Map<String, Object> create(HttpServletRequest request,
@RequestBody(required = false) String body) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("created", true);
out.put("received", body == null ? "" : body);
out.put("cookies", cookieNames(request));
return out;
}
@GetMapping("/api/whoami")
public Map<String, Object> whoami(Authentication authentication, HttpServletRequest request) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("name", authentication == null ? "(none)" : authentication.getName());
out.put("sessionId", request.getSession(false) == null ? "(no session)" : "present");
return out;
}
@GetMapping("/api/boom")
public Map<String, Object> boom() {
throw new IllegalStateException("deliberate failure, so you can watch the CORS headers vanish");
}
private static String cookieNames(HttpServletRequest request) {
if (request.getCookies() == null) {
return "(none)";
}
StringBuilder sb = new StringBuilder();
for (var c : request.getCookies()) {
sb.append(sb.isEmpty() ? "" : ",").append(c.getName());
}
return sb.toString();
}
}

View File

@@ -0,0 +1,73 @@
package com.ankurm.cors.web;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import jakarta.servlet.Filter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.env.Environment;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.cors.CorsConfigurationSource;
/**
* Prints runtime state that is otherwise invisible: which filters are actually in the chain,
* and which {@code CorsConfigurationSource} beans the context holds and what they are named.
*
* <p>The bean-name question matters more than it looks. {@code CorsConfigurer} resolves the
* source by the bean <em>name</em> {@code corsConfigurationSource}, while the code that decides
* whether to switch CORS on at all looks it up by <em>type</em>. See
* <a href="../../../../docs/02-who-resolves-the-source.md">docs/02-who-resolves-the-source.md</a>.
*
* <p>Delete this controller before shipping anything.
*/
@RestController
public class DiagController {
private final FilterChainProxy proxy;
private final Map<String, CorsConfigurationSource> sources;
private final Environment environment;
public DiagController(@Qualifier("springSecurityFilterChain") Filter springSecurityFilterChain,
Map<String, CorsConfigurationSource> sources, Environment environment) {
this.proxy = (FilterChainProxy) springSecurityFilterChain;
this.sources = sources;
this.environment = environment;
}
@GetMapping("/diag/chain")
public Map<String, Object> chain() {
Map<String, Object> out = new LinkedHashMap<>();
out.put("profiles", List.of(this.environment.getActiveProfiles()));
List<Map<String, Object>> chains = new ArrayList<>();
for (SecurityFilterChain chain : this.proxy.getFilterChains()) {
Map<String, Object> one = new LinkedHashMap<>();
one.put("size", chain.getFilters().size());
List<String> names = new ArrayList<>();
for (Filter filter : chain.getFilters()) {
names.add(filter.getClass().getSimpleName());
}
one.put("filters", names);
chains.add(one);
}
out.put("chains", chains);
return out;
}
@GetMapping("/diag/cors-sources")
public Map<String, Object> corsSources() {
Map<String, Object> out = new LinkedHashMap<>();
Map<String, String> byName = new LinkedHashMap<>();
this.sources.forEach((name, source) -> byName.put(name, source.getClass().getSimpleName()));
out.put("corsConfigurationSourceBeans", byName);
out.put("hasBeanNamedCorsConfigurationSource", this.sources.containsKey("corsConfigurationSource"));
return out;
}
}

View File

@@ -0,0 +1,25 @@
# Every scenario in the article is a profile. The default is `securitysource`, the
# configuration that works, so that a bare `mvn spring-boot:run` starts something sane.
spring:
application:
name: cors-csrf-samesite
profiles:
default: securitysource
server:
port: 8080
servlet:
session:
cookie:
# Boot writes exactly what you put here. It does NOT add `Secure` for you when
# same-site is `none`, which is the whole subject of docs/07-samesite.md. Flip
# SESSION_SAME_SITE / SESSION_SECURE from scripts/scenario-samesite.sh and read the
# emitted Set-Cookie header back.
same-site: ${SESSION_SAME_SITE:lax}
secure: ${SESSION_SECURE:false}
http-only: true
logging:
level:
org.springframework.security.web.csrf: ${CSRF_LOG_LEVEL:INFO}
org.springframework.web.cors: ${CORS_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,221 @@
package com.ankurm.cors;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Assertions that pin the <em>contract</em>: which status code a browser sees, and whether the
* response carries the header that decides whether the browser will show it. They are written
* against the same profiles the transcripts in {@code docs/output/} use, so a change in Spring
* Security that alters any of this breaks a test rather than a paragraph.
*
* <p>These run through {@code MockMvc} with {@code springSecurityFilterChain} applied. That
* exercises the filter chain, which is the layer under test; it does <em>not</em> exercise the
* container's error dispatch, which is why the {@code /error} finding is verified by the
* transcripts in {@code docs/output/09-error-dispatch.txt} rather than here. Noted rather than
* hidden: it is a real limit of this test setup.
*/
class CorsContractTests {
private static MockMvcTester tester(WebApplicationContext context) {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers
.springSecurity())
.build();
return MockMvcTester.create(mockMvc);
}
@Nested
@SpringBootTest
@ActiveProfiles("mvconly")
@DisplayName("CORS on the MVC layer only")
class MvcOnly {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the preflight is rejected by authorization, and carries no CORS header")
void preflightIsRejected() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(401)
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
}
@Test
@DisplayName("no CorsFilter is in the chain")
void noCorsFilter() {
assertThat(chainClassNames(this.context)).doesNotContain("CorsFilter");
}
}
@Nested
@SpringBootTest
@ActiveProfiles("mvcbridge")
@DisplayName("the same MVC configuration plus .cors(withDefaults())")
class MvcBridge {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the preflight is answered by CorsFilter with the MVC configuration")
void preflightSucceeds() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200)
.hasHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://spa.example.com");
}
@Test
@DisplayName("MVC's CorsRegistration supplies a max-age default that CorsConfiguration does not")
void mvcSuppliesMaxAge() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "1800");
}
@Test
@DisplayName("CorsFilter sits between HeaderWriterFilter and LogoutFilter")
void corsFilterPosition() {
var names = chainClassNames(this.context);
assertThat(names).contains("CorsFilter");
assertThat(names.indexOf("CorsFilter")).isGreaterThan(names.indexOf("HeaderWriterFilter"));
assertThat(names.indexOf("CorsFilter")).isLessThan(names.indexOf("AuthorizationFilter"));
}
}
@Nested
@SpringBootTest
@ActiveProfiles("securitysource")
@DisplayName("a bean named corsConfigurationSource")
class SecuritySource {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("no max-age is emitted, so every request re-runs the preflight")
void noMaxAge() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200)
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE);
}
@Test
@DisplayName("origin, method and header rejections are indistinguishable to the client")
void threeRejectionsLookIdentical() throws Exception {
var badOrigin = tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://evil.example.com")
.header("Access-Control-Request-Method", "POST").exchange();
var badMethod = tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "DELETE").exchange();
var badHeader = tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "authorization").exchange();
assertThat(badOrigin.getResponse().getStatus()).isEqualTo(403);
assertThat(badMethod.getResponse().getStatus()).isEqualTo(403);
assertThat(badHeader.getResponse().getStatus()).isEqualTo(403);
assertThat(badOrigin.getResponse().getContentAsString()).isEqualTo("Invalid CORS request");
assertThat(badMethod.getResponse().getContentAsString())
.isEqualTo(badOrigin.getResponse().getContentAsString());
assertThat(badHeader.getResponse().getContentAsString())
.isEqualTo(badOrigin.getResponse().getContentAsString());
}
@Test
@DisplayName("an unauthenticated request still carries the CORS header, so the SPA can read the 401")
void unauthenticatedStillCarriesCorsHeader() {
assertThat(tester(this.context).get().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com"))
.hasStatus(401)
.hasHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://spa.example.com");
}
@Test
@DisplayName("a path outside the registered pattern gets no CORS header at all")
void outsideThePatternGetsNothing() {
assertThat(tester(this.context).get().uri("/nope")
.header(HttpHeaders.ORIGIN, "https://spa.example.com"))
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
}
}
@Nested
@SpringBootTest
@ActiveProfiles("misnamed")
@DisplayName("the right type under the wrong bean name")
class Misnamed {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the context starts and CorsFilter is in the chain")
void itStarts() {
assertThat(chainClassNames(this.context)).contains("CorsFilter");
}
@Test
@DisplayName("the preflight returns 200 with no CORS headers - the most confusing state there is")
void twoHundredWithNothing() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200)
.doesNotContainHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN);
}
}
@Nested
@SpringBootTest
@ActiveProfiles("twosources")
@DisplayName("two UrlBasedCorsConfigurationSource beans")
class TwoSources {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("CORS is configured anyway, and the bean NAME decides which one wins")
void nameWins() {
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://spa.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(200);
assertThat(tester(this.context).options().uri("/api/data")
.header(HttpHeaders.ORIGIN, "https://admin.example.com")
.header("Access-Control-Request-Method", "POST"))
.hasStatus(403);
}
}
private static java.util.List<String> chainClassNames(WebApplicationContext context) {
var proxy = (org.springframework.security.web.FilterChainProxy) context
.getBean("springSecurityFilterChain");
return proxy.getFilterChains().get(proxy.getFilterChains().size() - 1).getFilters().stream()
.map((filter) -> filter.getClass().getSimpleName())
.toList();
}
}

View File

@@ -0,0 +1,193 @@
package com.ankurm.cors;
import java.util.List;
import com.ankurm.cors.spec.SpecCookieJar;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/** CSRF-for-SPAs behaviour, and the cookie rules that decide whether the token ever arrives. */
class CsrfAndCookieTests {
private static MockMvcTester tester(WebApplicationContext context) {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers
.springSecurity())
.build();
return MockMvcTester.create(mockMvc);
}
@Nested
@SpringBootTest
@ActiveProfiles("csrfnaive")
@DisplayName("CookieCsrfTokenRepository.withHttpOnlyFalse() on its own")
class Naive {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the bootstrap GET sets no cookie, because the token is deferred")
void bootstrapGetSetsNoCookie() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
assertThat(result.getResponse().getCookie("XSRF-TOKEN")).isNull();
}
}
@Nested
@SpringBootTest
@ActiveProfiles("csrfspa")
@DisplayName("csrf.spa()")
class Spa {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the bootstrap GET does set the cookie, because spa() resolves the token eagerly")
void bootstrapGetSetsCookie() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
var cookie = result.getResponse().getCookie("XSRF-TOKEN");
assertThat(cookie).isNotNull();
// The mechanism: spa() installs a handler whose XOR delegate has a null
// csrfRequestAttributeName, so CsrfTokenRequestAttributeHandler.handle falls back
// to token.getParameterName() for the attribute key - and calling that method on
// the SupplierCsrfToken is what dereferences the deferred token.
assertThat(cookie.getValue()).isNotEmpty();
}
@Test
@DisplayName("the cookie carries no SameSite and no Secure attribute")
void cookieHasNoSameSite() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
var cookie = result.getResponse().getCookie("XSRF-TOKEN");
assertThat(cookie).isNotNull();
assertThat(cookie.getSecure()).isFalse();
assertThat(cookie.getAttribute("SameSite")).isNull();
}
@Test
@DisplayName("the raw cookie value works in the header, which is the whole point of spa()")
void rawCookieValueIsAccepted() {
var tester = tester(this.context);
var bootstrap = tester.get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
var cookie = bootstrap.getResponse().getCookie("XSRF-TOKEN");
assertThat(tester.post().uri("/api/data")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}")
.cookie(cookie)
.header("X-XSRF-TOKEN", cookie.getValue())
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice")))
.hasStatus(200);
}
}
@Nested
@SpringBootTest
@ActiveProfiles("spaorder")
@DisplayName("csrfTokenRepository(..) before spa()")
class Ordering {
@Autowired
WebApplicationContext context;
@Test
@DisplayName("the custom repository is discarded and the default cookie name comes back")
void customRepositoryIsDiscarded() {
var result = tester(this.context).get().uri("/api/data")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("alice"))
.exchange();
assertThat(result.getResponse().getCookie("MY-CSRF")).isNull();
assertThat(result.getResponse().getCookie("XSRF-TOKEN")).isNotNull();
}
}
@Nested
@DisplayName("SpecCookieJar - the storage and sending rules a browser applies")
class Spec {
@Test
@DisplayName("SameSite=None without Secure is ignored entirely")
void noneWithoutSecureIsDropped() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/; SameSite=None", true))
.contains("RFC 6265bis");
assertThat(jar.stored()).isEmpty();
}
@Test
@DisplayName("Secure is not honoured from an untrustworthy origin, so None+Secure is dropped over plain http")
void secureNeedsATrustworthyOrigin() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/; Secure; SameSite=None", false)).isNotNull();
assertThat(jar.stored()).isEmpty();
}
@Test
@DisplayName("a cookie with no SameSite attribute behaves as Lax and is not sent on a cross-site fetch")
void absentSameSiteIsLax() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("XSRF-TOKEN=t; Path=/", true)).isNull();
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.SAME_SITE, true)).isEqualTo("XSRF-TOKEN=t");
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)).isEmpty();
}
@Test
@DisplayName("Lax is sent on a cross-site top-level navigation but not on a cross-site fetch")
void laxNavigationException() {
SpecCookieJar jar = new SpecCookieJar();
jar.setCookie("JSESSIONID=s; Path=/; SameSite=Lax", true);
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_TOP_LEVEL_NAVIGATION, true))
.isEqualTo("JSESSIONID=s");
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false)).isEmpty();
}
@Test
@DisplayName("only Secure + SameSite=None survives to a cross-site fetch")
void onlyNoneSecureSurvives() {
SpecCookieJar jar = new SpecCookieJar();
for (String header : List.of("a=1; Path=/", "b=2; Path=/; SameSite=Lax",
"c=3; Path=/; SameSite=Strict", "d=4; Path=/; Secure; SameSite=None")) {
jar.setCookie(header, true);
}
assertThat(jar.cookieHeaderFor(SpecCookieJar.Context.CROSS_SITE_SUBRESOURCE, false))
.isEqualTo("d=4");
}
@Test
@DisplayName("Partitioned without Secure is rejected too")
void partitionedNeedsSecure() {
SpecCookieJar jar = new SpecCookieJar();
assertThat(jar.setCookie("x=1; Path=/; Partitioned", true)).isNotNull();
}
}
@SuppressWarnings("unused")
private static final HttpHeaders UNUSED = null;
}