Declare a CorsConfigurationSource bean whose CorsConfiguration lists the front ends' exact origins from configuration with setAllowedOrigins and sets setAllowCredentials(true), and apply it with CorsFilter or http.cors(). Spring echoes only listed origins and answers other origins, null included, with 403. Never use allowedOriginPatterns("*") or a hand-written contains check.
- Find every CORS source: @CrossOrigin annotations, WebMvcConfigurer.addCorsMappings, CorsConfigurationSource and CorsFilter beans, http.cors(...) in a SecurityFilterChain, and filters that set Access-Control-Allow-Origin by hand.
- Put the front ends' origins in configuration, for example api.allowed-origins=https://portal.example.test,https://admin.example.test, with no default, so that a missing value stops the app from starting.
- Build one CorsConfiguration with setAllowedOrigins(allowedOrigins), only the methods and request headers the front ends use, setAllowCredentials(true) and setMaxAge, and register it for /api/** in a UrlBasedCorsConfigurationSource bean named corsConfigurationSource.
- Without Spring Security, register new CorsFilter(source) as a bean. With Spring Security, call http.cors(Customizer.withDefaults()) instead, which picks up the bean named corsConfigurationSource and handles CORS before authentication.
- Remove allowedOriginPatterns("*"), @CrossOrigin(originPatterns = "*") and similar wildcards from credentialed APIs, and keep null, http:// origins and localhost out of production configuration.
- Send Cache-Control: no-store (or private) on per-user API responses, keep CSRF protection on cookie-authenticated writes, and require a JSON body so that cross-origin writes need a preflight.
- Add regression tests that send credentialed requests from a foreign origin, Origin: null, look-alike hosts, the http:// version of a front end and a sibling subdomain, and assert that a browser would not share the response.
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
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// setAllowedOrigins(List.of("*")) failed with "When allowCredentials is true,
// allowedOrigins cannot contain the special value "*" ... consider using
// "allowedOriginPatterns" instead."
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("*"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}With IMPL=vulnerable, 8 of 13 tests fail (8 exploit tests).
The fix
@Bean
CorsConfigurationSource corsConfigurationSource(@Value("${api.allowed-origins}") List<String> allowedOrigins) {
CorsConfiguration api = new CorsConfiguration();
// https://portal.example.test and https://admin.example.test, from configuration. Exact
// match on scheme, host and port: no "*", no "null" and no allowedOriginPatterns.
api.setAllowedOrigins(allowedOrigins);
api.setAllowedMethods(List.of("GET", "PUT"));
api.setAllowedHeaders(List.of("Content-Type"));
api.setAllowCredentials(true);
api.setMaxAge(600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", api);
return source;
}With IMPL=fixed, 13 of 13 tests pass.
A fix that looks safe but is not
@Bean
CorsConfigurationSource corsConfigurationSource() {
return request -> {
CorsConfiguration config = new CorsConfiguration();
String origin = request.getHeader(HttpHeaders.ORIGIN);
if (origin != null && origin.contains("example.test")) { // portal, admin and whatever comes next
config.addAllowedOrigin(origin);
}
config.setAllowedMethods(List.of("GET", "PUT"));
config.setAllowedHeaders(List.of("Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(600L);
return config;
};
}With IMPL=wrong, 5 of 13 tests fail (5 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 request is what a page on the attacker's origin gets from
* fetch("https://api.example.test/api/invoices", {credentials: "include"}): the browser attaches
* Alice's cookie, and the API's CORS headers decide whether the page may read the reply.
*/
class ExploitTest extends CorsTestSupport {
@Test
void anArbitraryOriginCannotReadAlicesInvoices() throws Exception {
assertNotShared(readInvoices(EVIL), EVIL);
}
@Test
void originNullFromASandboxedIframeOrDataUrlCannotReadThem() throws Exception {
assertNotShared(readInvoices("null"), "null");
}
@Test
void aDomainThatMerelyEndsWithExampleTestCannotReadThem() throws Exception {
assertNotShared(readInvoices(SUFFIX_LOOKALIKE), SUFFIX_LOOKALIKE);
}
@Test
void aDomainThatStartsWithThePortalsOriginCannotReadThem() throws Exception {
assertNotShared(readInvoices(PREFIX_LOOKALIKE), PREFIX_LOOKALIKE);
}
@Test
void aHostThatMatchesOnlyBecauseARegexDotMatchesAnyCharacterCannotReadThem() throws Exception {
assertNotShared(readInvoices(DOT_LOOKALIKE), DOT_LOOKALIKE);
}
@Test
void thePortalsHostOverPlainHttpCannotReadThem() throws Exception {
// Anyone on the network path can inject script into an http:// page.
assertNotShared(readInvoices(PLAIN_HTTP_PORTAL), PLAIN_HTTP_PORTAL);
}
@Test
void aSiblingSubdomainThatIsNotOnTheListCannotReadThem() throws Exception {
// One XSS or subdomain takeover on forum.example.test would otherwise expose the API.
assertNotShared(readInvoices(SIBLING), SIBLING);
}
@Test
void anArbitraryOriginsPreflightIsRefusedSoItsCredentialedUpdateIsNeverSent() throws Exception {
Browser.Result result = changeEmail(EVIL, "mallory@evil.example");
assertThat(accounts.emailOf("alice"))
.as("the cross-origin PUT changed Alice's email (%s)", Browser.describeCors(result.response()))
.isEqualTo("alice@example.test");
assertThat(result.sent())
.as("the browser would send the PUT after this preflight (%s)", Browser.describeCors(result.response()))
.isFalse();
}
}- setAllowedOrigins(List.of("*")) with setAllowCredentials(true) throws IllegalArgumentException from CorsConfiguration.checkOrigin on the first CORS request, and the message suggests allowedOriginPatterns. allowedOriginPatterns("*") then echoes every origin with credentials, Origin: null included; that is this card's vulnerable implementation.
- allowedOriginPatterns turns * into .* and matches the whole origin. https://*.example.test pins the scheme and the dot, and refused https://portal.example.test.evil.example and https://example.test, but it accepts every subdomain, so one XSS or dangling DNS record on any of them can read the API.
- A CorsConfigurationSource that builds its configuration from the request's own Origin, as in impl/WrongCors.java, bypasses Spring's matching: whatever test it applies becomes the policy, and contains or endsWith tests accept look-alike domains.
- Spring rejects a CORS request from a refused origin with 403 "Invalid CORS request" before any controller runs. Requests without Origin, and requests whose Origin matches the server's own scheme, host and port, are not treated as CORS requests and pass through.
- DefaultCorsProcessor adds Vary: Origin, Access-Control-Request-Method and Access-Control-Request-Headers to every response under a CORS mapping, refusals and requests without Origin included, which is what the Fetch standard asks of a server whose CORS headers depend on the Origin; it also compares origins case-insensitively and ignores a trailing slash.
- With Spring Security, a CorsFilter bean that runs after the security filters is too late: the preflight carries no cookies, so Spring Security rejects it as unauthenticated. Use http.cors(...), which places CORS processing before authentication.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs cors-misconfiguration/java-spring does this for you).
- Against your own API, replay a credentialed request with a foreign origin, for example curl -i -H 'Origin: https://evil.example' -b 'JSESSIONID=...' https://your.api/api/invoices, and expect 403 without Access-Control-Allow-Origin; repeat with Origin: null, a look-alike host, the http:// version of your front end and a preflight (-X OPTIONS -H 'Access-Control-Request-Method: PUT').
- Grep for allowedOriginPatterns, @CrossOrigin, CorsConfigurationSource lambdas and setHeader("Access-Control-Allow-Origin": the Semgrep rule and Find Security Bugs only see headers set by hand to * or null.
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.lang.security.audit.permissive-cors.permissive-cors | No findings in src/main/java. The rule matches an Access-Control-Allow-Origin header set by hand to the literal "*" or "null" (HttpServletResponse.setHeader or addHeader, HttpHeaders.set, ResponseEntity.header and similar); it does not read CorsConfiguration, allowedOriginPatterns or a CorsConfigurationSource. On a probe it reported a servlet method that sets the header to "*" but not one that copies the request's Origin into it with credentials.Checked: Semgrep 1.180.0 (CE), semgrep-rules develop at 9bbf021 |
| CodeQL | java/unvalidated-cors-origin-set | Not expected to report anything on this card. It is an experimental query that tracks remote input into an Access-Control-Allow-Origin header set with setHeader or addHeader next to Access-Control-Allow-Credentials: true, unless the value passes a contains or equals check. Spring's CorsConfiguration is not modelled, and the wrong implementation's String.contains would count as a check.Checked: github/codeql main at b5b8165; not executed |
| Find Security Bugs (SpotBugs plugin) | PERMISSIVE_CORSOverly permissive CORS policy | The documented pattern is response.addHeader("Access-Control-Allow-Origin", "*"), a header set by hand; nothing on the page covers CorsConfiguration or allowedOriginPatterns.Checked: Bug pattern description read 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 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 (spring-web and spring-webmvc 7.0.9) |
| 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-webmvc-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 cors-misconfiguration/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 cors-misconfiguration/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
- springFramework 7.0.9
- tomcat 11.0.26
- junitJupiter 6.0.3
- Matrix
- fixed: 13 of 13 tests pass; vulnerable: 8 of 13 tests fail (8 exploit tests); wrong: 5 of 13 tests fail (5 exploit tests)
- Test results
- fixed 13/13 pass; vulnerable 8/13 fail (all 8 exploit tests); wrong 5/13 fail (5 exploit tests), offline after install
- Dependency freshness
- Spring Boot 4.1.1 is the current GA release (4.2.0-M2 is a milestone). Boot 4.1.1 manages Tomcat 11.0.24 and Jackson 3.1.5, which have published advisories fixed in Tomcat 11.0.26 and Jackson 3.1.7, so the pom overrides tomcat.version to 11.0.26 and jackson-bom.version to 3.1.7, as the other Java cards do. OSV listed no advisories for the 72 resolved artifacts on 2026-10-10. Maven has no lockfile; the Boot parent and the two overrides fix every version.
- Notes
- Spring behaviour checked with DefaultCorsProcessor probes on spring-web 7.0.9: allowedOrigins("*") with credentials threw IllegalArgumentException at request time; allowedOriginPatterns("*") echoed Origin: null with credentials; the fixed list answered a refused origin and a refused preflight with 403 and only Vary headers; https://*.example.test accepted https://forum.example.test and refused https://portal.example.test.evil.example and https://example.test. Spring Security 7.1.1's CorsConfigurer looks up the bean named corsConfigurationSource (or corsFilter), so the fixed bean works with http.cors() unchanged.
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
- Spring Framework reference: CORS (Web MVC)
- Spring Framework API: CorsConfiguration (setAllowedOrigins, setAllowedOriginPatterns, setAllowCredentials)
- Spring Security reference: CORS (processing CORS before Spring Security)
- WHATWG Fetch Standard: CORS protocol and the CORS check
- MDN: Cross-Origin Resource Sharing (CORS)
- OWASP HTML5 Security Cheat Sheet: Cross Origin Resource Sharing
- PortSwigger Web Security Academy: CORS