This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new ba5ed0f7bd fix(web): preserve CORS origin schemes and Vary headers
(#7901)
ba5ed0f7bd is described below
commit ba5ed0f7bd360ad892be459e59e439529673525c
Author: cloudbase-ai <[email protected]>
AuthorDate: Fri Oct 23 11:25:41 2026 +0800
fix(web): preserve CORS origin schemes and Vary headers (#7901)
Signed-off-by: cloudbase-ai <[email protected]>
Co-authored-by: cloudbase-ai
<[email protected]>
---
.../org/apache/shenyu/web/filter/CrossFilter.java | 19 +++-
.../apache/shenyu/web/filter/CrossFilterTest.java | 122 ++++++++++++++++++++-
2 files changed, 135 insertions(+), 6 deletions(-)
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/filter/CrossFilter.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/filter/CrossFilter.java
index 1edec14fb3..7bed716ed3 100644
--- a/shenyu-web/src/main/java/org/apache/shenyu/web/filter/CrossFilter.java
+++ b/shenyu-web/src/main/java/org/apache/shenyu/web/filter/CrossFilter.java
@@ -62,12 +62,20 @@ public class CrossFilter implements WebFilter {
@Override
@NonNull
public Mono<Void> filter(@NonNull final ServerWebExchange exchange,
@NonNull final WebFilterChain chain) {
+ // Vary also covers absent or denied origins, and must survive
upstream response headers.
+ exchange.getResponse().beforeCommit(() -> {
+ HttpHeaders headers = exchange.getResponse().getHeaders();
+ if (headers.getVary().stream().noneMatch(value ->
ALL.equals(value) || HttpHeaders.ORIGIN.equalsIgnoreCase(value))) {
+ headers.add(HttpHeaders.VARY, HttpHeaders.ORIGIN);
+ }
+ return Mono.empty();
+ });
ServerHttpRequest request = exchange.getRequest();
- if (CorsUtils.isCorsRequest(request)) {
+ final String origin = request.getHeaders().getOrigin();
+ if (!"null".equals(origin) && CorsUtils.isCorsRequest(request)) {
ServerHttpResponse response = exchange.getResponse();
HttpHeaders headers = response.getHeaders();
// finger allow origins
- final String origin = request.getHeaders().getOrigin();
boolean allowCors = this.filterConfig.isAllowedAnyOrigin();
if (!allowCors &&
Objects.nonNull(this.filterConfig.getAllowedOrigin())) {
final String scheme =
exchange.getRequest().getURI().getScheme();
@@ -85,11 +93,12 @@ public class CrossFilter implements WebFilter {
allowedOrigin.addAll(Optional.ofNullable(allowedOriginConfig.getOrigins()).orElse(Collections.emptySet())
.stream()
.filter(StringUtils::isNoneBlank)
+ .map(String::trim)
.map(oneOrigin -> {
- if (ALL.equals(oneOrigin) ||
oneOrigin.startsWith(String.format("%s://", scheme))) {
- return oneOrigin.trim();
+ if (ALL.equals(oneOrigin) ||
oneOrigin.contains("://")) {
+ return oneOrigin;
}
- return String.format("%s://%s", scheme,
oneOrigin.trim());
+ return String.format("%s://%s", scheme, oneOrigin);
})
.collect(Collectors.toSet()));
allowCors = allowedOrigin.contains(origin) ||
allowedOrigin.contains(ALL);
diff --git
a/shenyu-web/src/test/java/org/apache/shenyu/web/filter/CrossFilterTest.java
b/shenyu-web/src/test/java/org/apache/shenyu/web/filter/CrossFilterTest.java
index 79a74a15ca..69636449db 100644
--- a/shenyu-web/src/test/java/org/apache/shenyu/web/filter/CrossFilterTest.java
+++ b/shenyu-web/src/test/java/org/apache/shenyu/web/filter/CrossFilterTest.java
@@ -20,6 +20,9 @@ package org.apache.shenyu.web.filter;
import org.apache.shenyu.common.config.ShenyuConfig.CrossFilterConfig;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
@@ -32,6 +35,7 @@ import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.util.HashSet;
+import java.util.Set;
import java.util.regex.Pattern;
import static org.mockito.Mockito.mock;
@@ -231,9 +235,125 @@ public final class CrossFilterTest {
assertOrigin(filter, "https://other.example", false);
}
+ @ParameterizedTest
+ @CsvSource({
+ "http://gateway.example, https://frontend.example,
https://frontend.example",
+ "https://gateway.example, http://frontend.example,
http://frontend.example",
+ "http://gateway.example, http://frontend.example,
http://frontend.example",
+ "https://gateway.example, frontend.example:8443,
https://frontend.example:8443"
+ })
+ void testAllowedOriginsPreserveTheirOwnScheme(final String gateway, final
String configuredOrigin, final String origin) {
+ CrossFilterConfig config = new CrossFilterConfig();
+ config.getAllowedOrigin().setOrigins(Set.of(" " + configuredOrigin +
" "));
+ config.setAllowCredentials(true);
+ ServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.get(gateway).header(HttpHeaders.ORIGIN,
origin));
+ WebFilterChain chain = mock(WebFilterChain.class);
+ when(chain.filter(exchange)).thenReturn(Mono.empty());
+
+ filterAndComplete(new CrossFilter(config), exchange, chain);
+
+ verify(chain).filter(exchange);
+ Assertions.assertEquals(origin,
exchange.getResponse().getHeaders().getAccessControlAllowOrigin());
+
Assertions.assertTrue(exchange.getResponse().getHeaders().getAccessControlAllowCredentials());
+
Assertions.assertTrue(exchange.getResponse().getHeaders().getVary().contains(HttpHeaders.ORIGIN));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"http://frontend.example",
"https://frontend.example:8443", "https://other.example",
"https://frontend.example.evil", "null"})
+ void testAllowListDoesNotReflectDisallowedOrigins(final String origin) {
+ CrossFilterConfig config = new CrossFilterConfig();
+
config.getAllowedOrigin().setOrigins(Set.of("https://frontend.example"));
+ ServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.options("http://gateway.example").header(HttpHeaders.ORIGIN,
origin));
+ WebFilterChain chain = mock(WebFilterChain.class);
+ when(chain.filter(exchange)).thenReturn(Mono.empty());
+
+ filterAndComplete(new CrossFilter(config), exchange, chain);
+
+ verify(chain).filter(exchange);
+
Assertions.assertNull(exchange.getResponse().getHeaders().getAccessControlAllowOrigin());
+
Assertions.assertNull(exchange.getResponse().getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS));
+ Assertions.assertNull(exchange.getResponse().getStatusCode());
+
Assertions.assertTrue(exchange.getResponse().getHeaders().getVary().contains(HttpHeaders.ORIGIN));
+ }
+
+ @Test
+ void testAllowedCrossSchemePreflightReturnsConfiguredHeaders() {
+ CrossFilterConfig config = new CrossFilterConfig();
+
config.getAllowedOrigin().setOrigins(Set.of("https://frontend.example"));
+ config.setAllowedMethods("GET,POST,OPTIONS");
+ config.setAllowedHeaders("Content-Type,Authorization");
+ ServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.options("http://gateway.example")
+ .header(HttpHeaders.ORIGIN, "https://frontend.example")
+ .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
+ .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS,
"Authorization")
+ .build());
+ WebFilterChain chain = mock(WebFilterChain.class);
+ when(chain.filter(exchange)).thenReturn(Mono.empty());
+
+ filterAndComplete(new CrossFilter(config), exchange, chain);
+
+ verify(chain, never()).filter(exchange);
+ HttpHeaders headers = exchange.getResponse().getHeaders();
+ Assertions.assertEquals(HttpStatus.OK,
exchange.getResponse().getStatusCode());
+ Assertions.assertEquals("https://frontend.example",
headers.getAccessControlAllowOrigin());
+ Assertions.assertEquals(Set.of("GET", "POST", "OPTIONS"),
+
Set.of(headers.getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS).split(",")));
+
Assertions.assertTrue(headers.getAccessControlAllowHeaders().containsAll(Set.of("Content-Type",
"Authorization")));
+ Assertions.assertTrue(headers.getVary().contains(HttpHeaders.ORIGIN));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"Accept-Encoding", "Accept-Encoding, Origin",
"Accept-Encoding, origin", "*"})
+ void testVaryPreservesExistingTokensWithoutDuplicatingOrigin(final String
vary) {
+ CrossFilterConfig config = new CrossFilterConfig();
+ config.setAllowedAnyOrigin(true);
+ ServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.get("http://gateway.example")
+ .header(HttpHeaders.ORIGIN, "https://frontend.example"));
+
+ filterAndComplete(new CrossFilter(config), exchange,
+ ignored -> Mono.fromRunnable(() ->
exchange.getResponse().getHeaders().set(HttpHeaders.VARY, vary)));
+
+ HttpHeaders headers = exchange.getResponse().getHeaders();
+ Assertions.assertEquals("https://frontend.example",
headers.getAccessControlAllowOrigin());
+ Assertions.assertEquals("*".equals(vary) ? 0L : 1L,
headers.getVary().stream().filter(HttpHeaders.ORIGIN::equalsIgnoreCase).count());
+ Assertions.assertTrue(headers.getVary().contains("*".equals(vary) ?
"*" : "Accept-Encoding"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testWildcardModesReflectConcreteOriginsButNotNull(final boolean
anyOrigin) {
+ CrossFilterConfig config = new CrossFilterConfig();
+ config.setAllowedAnyOrigin(anyOrigin);
+ config.setAllowCredentials(true);
+ config.getAllowedOrigin().setOrigins(Set.of("*"));
+ CrossFilter filter = new CrossFilter(config);
+ assertOrigin(filter, "https://frontend.example", true);
+ assertOrigin(filter, "null", false);
+ }
+
+ @Test
+ void testResponseWithoutOriginAlsoVariesByOrigin() {
+ CrossFilterConfig config = new CrossFilterConfig();
+ config.setAllowedAnyOrigin(true);
+ ServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.get("http://gateway.example"));
+ WebFilterChain chain = mock(WebFilterChain.class);
+ when(chain.filter(exchange)).thenReturn(Mono.empty());
+
+ filterAndComplete(new CrossFilter(config), exchange, chain);
+
+ verify(chain).filter(exchange);
+
Assertions.assertNull(exchange.getResponse().getHeaders().getAccessControlAllowOrigin());
+
Assertions.assertNull(exchange.getResponse().getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS));
+
Assertions.assertTrue(exchange.getResponse().getHeaders().getVary().contains(HttpHeaders.ORIGIN));
+ }
+
private void assertOrigin(final CrossFilter filter, final String origin,
final boolean allowed) {
ServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost").header(HttpHeaders.ORIGIN,
origin));
- StepVerifier.create(filter.filter(exchange, ignored ->
Mono.empty())).verifyComplete();
+ filterAndComplete(filter, exchange, ignored -> Mono.empty());
Assertions.assertEquals(allowed ? origin : null,
exchange.getResponse().getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN));
}
+
+ private void filterAndComplete(final CrossFilter filter, final
ServerWebExchange exchange, final WebFilterChain chain) {
+ StepVerifier.create(filter.filter(exchange,
chain).then(Mono.defer(exchange.getResponse()::setComplete))).verifyComplete();
+ }
}