EntryStoreCorsConfigurationSource.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.configuration;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.Nullable;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import java.util.List;
import java.util.Locale;
/**
* Resolves the CORS policy per request from two policies fixed at startup: one for origins allowed
* to send credentials, one for the rest. Both are built once in the constructor and only ever read,
* so nothing here mutates after the context is up.
*
* <p>Origin matching is delegated to {@link CorsConfiguration#checkOrigin}, driven by
* {@link CorsConfiguration#setAllowedOriginPatterns} — that supports the {@code *.suffix},
* {@code prefix.*} and bare {@code *} forms EntryStore documents. {@code setAllowedOrigins} would
* not work for the startup policies: {@code checkOrigin} rejects a literal {@code *} there when
* credentials are allowed, whereas patterns are exempt and still echo the request origin rather than
* emitting {@code *}.
*
* <p>Patterns outside the documented forms are dropped at startup with an ERROR naming them. Spring
* treats <em>every</em> {@code *} as a wildcard regardless of position, so honouring e.g.
* {@code https://*.example.com} — which the previous hand-rolled matcher silently matched against
* nothing — would widen the effective policy on upgrade, in the worst case handing
* {@code Access-Control-Allow-Credentials: true} to a whole subdomain tree. Dropping keeps such a
* pattern matching nothing, exactly as before.
*
* <p>Matching is case-insensitive, as it was before the policy moved onto Spring's pattern support:
* the configured patterns are lower-cased by {@link CorsProperties} and the request origin is
* lower-cased here. Normalising the origin alone would not be enough, because
* {@code DefaultCorsProcessor} re-runs {@code checkOrigin} with the <em>unmodified</em> header and
* rejects with 403 when that second check fails. An origin that needed normalising to match therefore
* gets a copy of the policy carrying the raw header in {@code allowedOrigins}, which is compared with
* {@code equalsIgnoreCase}. Origins that are already lower case — everything a browser sends — match
* the shared policy directly and get it back untouched.
*
* <p>Returning {@code null} for an origin that matches neither list means a <b>simple</b> request
* passes through unchanged, without any {@code Access-Control-*} headers, leaving it to the browser
* to block the response. A <b>preflight</b> from such an origin is short-circuited by
* {@code CorsFilter}: {@code DefaultCorsProcessor.processRequest} returns {@code true} when no
* configuration is resolved, and the filter ends every preflight without invoking the chain — so
* the answer is 200 with no CORS headers, which the browser treats as a preflight failure.
*
* <p>The bean name is load-bearing and must stay {@code corsConfigurationSource}: Spring Security's
* {@code CorsConfigurer} resolves the source by that exact name, and when no such bean exists it
* silently falls back to the Spring MVC {@code HandlerMappingIntrospector} — which only knows about
* {@code @CrossOrigin} and {@code addCorsMappings}, so every CORS header would just disappear.
*/
@Slf4j
@Component("corsConfigurationSource")
public class EntryStoreCorsConfigurationSource implements CorsConfigurationSource {
private static final List<String> ALLOWED_METHODS = List.of("HEAD", "GET", "PUT", "POST", "DELETE", "OPTIONS");
private record Policies(CorsConfiguration credential, CorsConfiguration standard) {
}
/** Null when CORS is disabled — there is then no policy to hand out at all. */
private final @Nullable Policies policies;
public EntryStoreCorsConfigurationSource(CorsProperties properties) {
if (!properties.enabled()) {
log.info("CORS is disabled");
this.policies = null;
return;
}
log.info("CORS is enabled");
List<String> originPatterns = rejectUndocumentedWildcards(properties.originPatterns());
List<String> credentialOriginPatterns = rejectUndocumentedWildcards(properties.credentialOriginPatterns());
originPatterns.forEach(pattern -> log.info("CORS allowed origin: {}", pattern));
credentialOriginPatterns.forEach(pattern -> log.info("CORS allowed origin (with credentials): {}", pattern));
if (originPatterns.isEmpty() && credentialOriginPatterns.isEmpty()) {
log.warn("CORS is enabled but no origin is allowed — every cross-origin request will pass through "
+ "without CORS headers; check entrystore.cors.origins");
}
List<String> headers = properties.headerList();
if (!headers.isEmpty()) {
log.info("CORS allowed/exposed headers: {}", headers);
}
if (properties.maxAge() > -1) {
log.info("CORS max age: {}", properties.maxAge());
}
this.policies = new Policies(
buildPolicy(credentialOriginPatterns, true, headers, properties.maxAge()),
buildPolicy(originPatterns, false, headers, properties.maxAge()));
}
@Override
public @Nullable CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
String origin = request.getHeader(HttpHeaders.ORIGIN);
if (policies == null || origin == null) {
return null;
}
String normalizedOrigin = origin.toLowerCase(Locale.ROOT);
CorsConfiguration matched = matchingPolicy(policies, normalizedOrigin);
if (matched == null) {
return null;
}
if (origin.equals(normalizedOrigin)) {
return matched;
}
// The origin matched only once lower-cased, but DefaultCorsProcessor re-runs checkOrigin with
// the unmodified header and rejects with 403 if that second check fails. Hand back a copy
// carrying the raw origin in allowedOrigins, which is compared with equalsIgnoreCase.
// Only replacing setters are safe on the copy: its constructor shares the list references of
// allowedOriginPatterns, allowedMethods, allowedHeaders and exposedHeaders with the startup
// policy, so an add* would write through into the policy every other request sees.
CorsConfiguration perRequest = new CorsConfiguration(matched);
perRequest.setAllowedOrigins(List.of(origin));
return perRequest;
}
/** Credentials first: an origin on both lists must keep getting {@code Allow-Credentials: true}. */
private static @Nullable CorsConfiguration matchingPolicy(Policies policies, String normalizedOrigin) {
if (policies.credential().checkOrigin(normalizedOrigin) != null) {
return policies.credential();
}
if (policies.standard().checkOrigin(normalizedOrigin) != null) {
return policies.standard();
}
return null;
}
private static List<String> rejectUndocumentedWildcards(List<String> originPatterns) {
return originPatterns.stream()
.filter(pattern -> {
if (isDocumentedForm(pattern)) {
return true;
}
log.error("Ignoring CORS origin pattern '{}': the supported forms are '*', '*suffix' and "
+ "'prefix*'. Spring treats every '*' as a wildcard, so honouring this pattern would "
+ "allow origins the previous matcher did not", pattern);
return false;
})
.toList();
}
private static boolean isDocumentedForm(String pattern) {
int star = pattern.indexOf('*');
return star < 0 || (star == pattern.lastIndexOf('*') && (star == 0 || star == pattern.length() - 1));
}
private static CorsConfiguration buildPolicy(List<String> originPatterns, boolean allowCredentials,
List<String> headers, int maxAge) {
CorsConfiguration policy = new CorsConfiguration();
if (!originPatterns.isEmpty()) {
policy.setAllowedOriginPatterns(originPatterns);
}
policy.setAllowedMethods(ALLOWED_METHODS);
policy.setAllowCredentials(allowCredentials);
if (!headers.isEmpty()) {
policy.setAllowedHeaders(headers);
policy.setExposedHeaders(headers);
}
if (maxAge > -1) {
policy.setMaxAge((long) maxAge);
}
return policy;
}
}