From cd545107bf94b5a2b9d423ac80449114b06159b2 Mon Sep 17 00:00:00 2001 From: ac892247 Date: Thu, 16 Jul 2026 11:17:59 +0200 Subject: [PATCH 01/11] verify content type for login Signed-off-by: ac892247 --- .../common/filter/ContentTypeFilter.java | 59 +++++++++ .../common/filter/ContentTypeFilterTest.java | 107 ++++++++++++++++ .../org/zowe/apiml/WebSecurityConfig.java | 2 + .../zowe/apiml/filter/ContentTypeFilter.java | 50 ++++++++ .../apiml/filter/ContentTypeFilterTest.java | 118 ++++++++++++++++++ .../config/NewSecurityConfiguration.java | 4 +- 6 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 apiml-security-common/src/main/java/org/zowe/apiml/security/common/filter/ContentTypeFilter.java create mode 100644 apiml-security-common/src/test/java/org/zowe/apiml/security/common/filter/ContentTypeFilterTest.java create mode 100644 apiml/src/main/java/org/zowe/apiml/filter/ContentTypeFilter.java create mode 100644 apiml/src/test/java/org/zowe/apiml/filter/ContentTypeFilterTest.java diff --git a/apiml-security-common/src/main/java/org/zowe/apiml/security/common/filter/ContentTypeFilter.java b/apiml-security-common/src/main/java/org/zowe/apiml/security/common/filter/ContentTypeFilter.java new file mode 100644 index 0000000000..da75d18b90 --- /dev/null +++ b/apiml-security-common/src/main/java/org/zowe/apiml/security/common/filter/ContentTypeFilter.java @@ -0,0 +1,59 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.security.common.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Rejects requests that carry a body with HTTP 415 unless they declare a {@code Content-Type} + * compatible with {@code application/json}. Bodyless requests (e.g. cert or Basic-Auth login, + * logout) pass through unchecked, since they have nothing to be misinterpreted as JSON. + *

+ * Body presence is determined from the {@code Content-Length}/{@code Transfer-Encoding} headers + * rather than by reading the request body, since the body stream is only safely readable once + * downstream (e.g. by {@code LoginFilter}). + */ +public class ContentTypeFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { + boolean hasBody = request.getContentLengthLong() > 0 || request.getHeader(HttpHeaders.TRANSFER_ENCODING) != null; + if (hasBody && !hasJsonContentType(request)) { + response.setStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE.value()); + return; + } + filterChain.doFilter(request, response); + } + + private boolean hasJsonContentType(HttpServletRequest request) { + String contentType = request.getContentType(); + if (contentType == null) { + return false; + } + try { + return MediaType.parseMediaType(contentType).isCompatibleWith(MediaType.APPLICATION_JSON); + } catch (InvalidMediaTypeException e) { + return false; + } + } + +} diff --git a/apiml-security-common/src/test/java/org/zowe/apiml/security/common/filter/ContentTypeFilterTest.java b/apiml-security-common/src/test/java/org/zowe/apiml/security/common/filter/ContentTypeFilterTest.java new file mode 100644 index 0000000000..792dab3e6f --- /dev/null +++ b/apiml-security-common/src/test/java/org/zowe/apiml/security/common/filter/ContentTypeFilterTest.java @@ -0,0 +1,107 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.security.common.filter; + +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ContentTypeFilterTest { + + private ContentTypeFilter filter; + private MockHttpServletResponse response; + private MockFilterChain chain; + + @BeforeEach + void setUp() { + filter = new ContentTypeFilter(); + response = new MockHttpServletResponse(); + chain = new MockFilterChain(); + } + + @Nested + class WhenRequestHasBody { + + @Test + void givenJsonContentType_thenRequestProceeds() throws ServletException, IOException { + var request = new MockHttpServletRequest("POST", "/auth/login"); + request.setContentType(MediaType.APPLICATION_JSON_VALUE); + request.setContent("{}".getBytes(StandardCharsets.UTF_8)); + + filter.doFilter(request, response, chain); + + assertEquals(request, chain.getRequest()); + } + + @Test + void givenNoContentType_thenRejectedWithUnsupportedMediaType() throws ServletException, IOException { + var request = new MockHttpServletRequest("POST", "/auth/login"); + request.setContent("{}".getBytes(StandardCharsets.UTF_8)); + + filter.doFilter(request, response, chain); + + assertEquals(HttpStatus.UNSUPPORTED_MEDIA_TYPE.value(), response.getStatus()); + assertNull(chain.getRequest()); + } + + @Test + void givenNonJsonContentType_thenRejectedWithUnsupportedMediaType() throws ServletException, IOException { + var request = new MockHttpServletRequest("POST", "/auth/login"); + request.setContentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE); + request.setContent("username=a&password=b".getBytes(StandardCharsets.UTF_8)); + + filter.doFilter(request, response, chain); + + assertEquals(HttpStatus.UNSUPPORTED_MEDIA_TYPE.value(), response.getStatus()); + assertNull(chain.getRequest()); + } + + @Test + void givenChunkedTransferEncodingAndNoContentType_thenRejectedWithUnsupportedMediaType() throws ServletException, IOException { + var request = new MockHttpServletRequest("POST", "/auth/login"); + request.addHeader(HttpHeaders.TRANSFER_ENCODING, "chunked"); + + filter.doFilter(request, response, chain); + + assertEquals(HttpStatus.UNSUPPORTED_MEDIA_TYPE.value(), response.getStatus()); + assertNull(chain.getRequest()); + } + + } + + @Nested + class WhenRequestHasNoBody { + + @Test + void givenNoContentTypeAndNoBody_thenRequestProceeds() throws ServletException, IOException { + var request = new MockHttpServletRequest("POST", "/auth/logout"); + + filter.doFilter(request, response, chain); + + assertEquals(request, chain.getRequest()); + } + + } + +} diff --git a/apiml/src/main/java/org/zowe/apiml/WebSecurityConfig.java b/apiml/src/main/java/org/zowe/apiml/WebSecurityConfig.java index 1aaccd9418..32b78bf8b1 100644 --- a/apiml/src/main/java/org/zowe/apiml/WebSecurityConfig.java +++ b/apiml/src/main/java/org/zowe/apiml/WebSecurityConfig.java @@ -42,6 +42,7 @@ import org.zowe.apiml.constants.ApimlConstants; import org.zowe.apiml.filter.BasicLoginFilter; import org.zowe.apiml.filter.CachedBodyFilter; +import org.zowe.apiml.filter.ContentTypeFilter; import org.zowe.apiml.security.common.filter.CategorizeCertsWebFilter; import org.zowe.apiml.filter.LogoutHandler; import org.zowe.apiml.filter.OIDCAuthFilter; @@ -417,6 +418,7 @@ SecurityWebFilterChain loginAndLogoutSecurityWebFilterChain(ServerHttpSecurity h .logoutSuccessHandler(new HttpStatusReturningServerLogoutSuccessHandler(HttpStatus.NO_CONTENT))) .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) .addFilterAfter(new CachedBodyFilter(), SecurityWebFiltersOrder.FIRST) + .addFilterAfter(new ContentTypeFilter(), SecurityWebFiltersOrder.FIRST) .addFilterAfter(new CategorizeCertsWebFilter(publicKeyCertificatesBase64, certificateValidator), SecurityWebFiltersOrder.FIRST) .addFilterAfter(new BasicLoginFilter(compoundAuthProvider, failedAuthenticationWebHandler), SecurityWebFiltersOrder.AUTHENTICATION) .addFilterAfter(new X509AuthFilter(reactiveX509provider), SecurityWebFiltersOrder.AUTHENTICATION); diff --git a/apiml/src/main/java/org/zowe/apiml/filter/ContentTypeFilter.java b/apiml/src/main/java/org/zowe/apiml/filter/ContentTypeFilter.java new file mode 100644 index 0000000000..0dd968db41 --- /dev/null +++ b/apiml/src/main/java/org/zowe/apiml/filter/ContentTypeFilter.java @@ -0,0 +1,50 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.filter; + +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +/** + * Rejects requests that carry a body with HTTP 415 unless they declare a {@code Content-Type} + * compatible with {@code application/json}. Bodyless requests (e.g. cert or Basic-Auth login, + * logout) pass through unchecked, since they have nothing to be misinterpreted as JSON. + *

+ * Must run after {@link CachedBodyFilter}: whether a body was actually sent is determined from + * {@link CachedBodyFilter#CACHED_BODY_ATTR} rather than the {@code Content-Length} header, which + * is absent for chunked/proxied requests. + */ +public class ContentTypeFilter implements WebFilter, Ordered { + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + boolean hasBody = exchange.getAttribute(CachedBodyFilter.CACHED_BODY_ATTR) != null; + if (hasBody) { + MediaType contentType = exchange.getRequest().getHeaders().getContentType(); + if (contentType == null || !contentType.isCompatibleWith(MediaType.APPLICATION_JSON)) { + exchange.getResponse().setStatusCode(HttpStatus.UNSUPPORTED_MEDIA_TYPE); + return exchange.getResponse().setComplete(); + } + } + return chain.filter(exchange); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } + +} diff --git a/apiml/src/test/java/org/zowe/apiml/filter/ContentTypeFilterTest.java b/apiml/src/test/java/org/zowe/apiml/filter/ContentTypeFilterTest.java new file mode 100644 index 0000000000..3cf3e12385 --- /dev/null +++ b/apiml/src/test/java/org/zowe/apiml/filter/ContentTypeFilterTest.java @@ -0,0 +1,118 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.filter; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ContentTypeFilterTest { + + private ContentTypeFilter filter; + + @BeforeEach + void setUp() { + this.filter = new ContentTypeFilter(); + } + + @Nested + class WhenRequestHasBody { + + @Test + void givenJsonContentType_thenRequestProceeds() { + var request = MockServerHttpRequest.post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body("{}"); + + var exchange = MockServerWebExchange.from(request); + exchange.getAttributes().put(CachedBodyFilter.CACHED_BODY_ATTR, "{}"); + var chain = mock(WebFilterChain.class); + when(chain.filter(any())).thenReturn(Mono.empty()); + + StepVerifier.create(filter.filter(exchange, chain)) + .verifyComplete(); + + verify(chain).filter(exchange); + } + + @Test + void givenNoContentType_thenRejectedWithUnsupportedMediaType() { + var request = MockServerHttpRequest.post("/auth/login") + .body("{}"); + + var exchange = MockServerWebExchange.from(request); + exchange.getAttributes().put(CachedBodyFilter.CACHED_BODY_ATTR, "{}"); + var chain = mock(WebFilterChain.class); + + StepVerifier.create(filter.filter(exchange, chain)) + .verifyComplete(); + + assertEquals(HttpStatus.UNSUPPORTED_MEDIA_TYPE, exchange.getResponse().getStatusCode()); + verifyNoInteractions(chain); + } + + @Test + void givenNonJsonContentType_thenRejectedWithUnsupportedMediaType() { + var request = MockServerHttpRequest.post("/auth/login") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body("username=a&password=b"); + + var exchange = MockServerWebExchange.from(request); + exchange.getAttributes().put(CachedBodyFilter.CACHED_BODY_ATTR, "username=a&password=b"); + var chain = mock(WebFilterChain.class); + + StepVerifier.create(filter.filter(exchange, chain)) + .verifyComplete(); + + assertEquals(HttpStatus.UNSUPPORTED_MEDIA_TYPE, exchange.getResponse().getStatusCode()); + verifyNoInteractions(chain); + } + + } + + @Nested + class WhenRequestHasNoBody { + + @Test + void givenNoContentTypeAndNoBody_thenRequestProceeds() { + var request = MockServerHttpRequest.post("/auth/logout") + .build(); + + var exchange = MockServerWebExchange.from(request); + var chain = mock(WebFilterChain.class); + when(chain.filter(any())).thenReturn(Mono.empty()); + + StepVerifier.create(filter.filter(exchange, chain)) + .verifyComplete(); + + verify(chain).filter(exchange); + } + + } + +} diff --git a/zaas-service/src/main/java/org/zowe/apiml/zaas/security/config/NewSecurityConfiguration.java b/zaas-service/src/main/java/org/zowe/apiml/zaas/security/config/NewSecurityConfiguration.java index 448d79fc3c..5186e52429 100644 --- a/zaas-service/src/main/java/org/zowe/apiml/zaas/security/config/NewSecurityConfiguration.java +++ b/zaas-service/src/main/java/org/zowe/apiml/zaas/security/config/NewSecurityConfiguration.java @@ -50,6 +50,7 @@ import org.zowe.apiml.security.common.content.CookieContentFilter; import org.zowe.apiml.security.common.error.AuthExceptionHandler; import org.zowe.apiml.security.common.filter.CategorizeCertsFilter; +import org.zowe.apiml.security.common.filter.ContentTypeFilter; import org.zowe.apiml.security.common.filter.StoreAccessTokenInfoFilter; import org.zowe.apiml.security.common.handler.FailedAccessTokenHandler; import org.zowe.apiml.security.common.handler.FailedAuthenticationHandler; @@ -167,7 +168,8 @@ private class CustomSecurityFilters extends AbstractHttpConfigurer Date: Thu, 16 Jul 2026 15:11:09 +0200 Subject: [PATCH 02/11] expect gateway to reject login with incorrect content type Signed-off-by: ac892247 --- .../authentication/providers/LoginTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/providers/LoginTest.java b/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/providers/LoginTest.java index aa7f2eafa9..606f2d5384 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/providers/LoginTest.java +++ b/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/providers/LoginTest.java @@ -47,10 +47,12 @@ import static io.restassured.RestAssured.given; import static io.restassured.http.ContentType.JSON; +import static io.restassured.http.ContentType.TEXT; import static org.apache.http.HttpStatus.SC_BAD_REQUEST; import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED; import static org.apache.http.HttpStatus.SC_NO_CONTENT; import static org.apache.http.HttpStatus.SC_UNAUTHORIZED; +import static org.apache.http.HttpStatus.SC_UNSUPPORTED_MEDIA_TYPE; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.startsWith; @@ -355,6 +357,20 @@ void loginEndpointActsAsExpected(String testName, URI loginUrl, RestAssuredConfi } + @ParameterizedTest(name = "givenTextContentTypeWithBody {index} {0} ") + @MethodSource("org.zowe.apiml.integration.authentication.providers.LoginTest#loginUrlsSource") + void givenTextContentTypeWithBody_thenUnsupportedMediaType(URI loginUrl) { + LoginRequest loginRequest = new LoginRequest(getUsername(), getPassword().toCharArray()); + + given() + .contentType(TEXT) + .body(loginRequest) + .when() + .post(loginUrl) + .then() + .statusCode(is(SC_UNSUPPORTED_MEDIA_TYPE)); + } + } private String getPath(URI loginUrl) { From a2dad4a28df7c96bd0fbd0e1cfa7f9cd4264309f Mon Sep 17 00:00:00 2001 From: ac892247 Date: Fri, 17 Jul 2026 08:29:49 +0200 Subject: [PATCH 03/11] pass string as body Signed-off-by: ac892247 --- .../authentication/providers/LoginTest.java | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/providers/LoginTest.java b/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/providers/LoginTest.java index 606f2d5384..1fae001621 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/providers/LoginTest.java +++ b/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/providers/LoginTest.java @@ -10,6 +10,7 @@ package org.zowe.apiml.integration.authentication.providers; +import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.Claims; import io.restassured.RestAssured; import io.restassured.config.RestAssuredConfig; @@ -48,24 +49,15 @@ import static io.restassured.RestAssured.given; import static io.restassured.http.ContentType.JSON; import static io.restassured.http.ContentType.TEXT; -import static org.apache.http.HttpStatus.SC_BAD_REQUEST; -import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED; -import static org.apache.http.HttpStatus.SC_NO_CONTENT; -import static org.apache.http.HttpStatus.SC_UNAUTHORIZED; -import static org.apache.http.HttpStatus.SC_UNSUPPORTED_MEDIA_TYPE; -import static org.hamcrest.CoreMatchers.containsString; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.startsWith; +import static org.apache.http.HttpStatus.*; +import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsNot.not; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.zowe.apiml.integration.zaas.ZaasTestUtil.isTestForICSF; -import static org.zowe.apiml.util.SecurityUtils.COOKIE_NAME; -import static org.zowe.apiml.util.SecurityUtils.assertThatTokenIsValid; -import static org.zowe.apiml.util.SecurityUtils.assertValidAuthToken; -import static org.zowe.apiml.util.SecurityUtils.parseJwtStringUnsecure; +import static org.zowe.apiml.util.SecurityUtils.*; import static org.zowe.apiml.util.requests.Endpoints.JWK_CURRENT; import static org.zowe.apiml.util.requests.Endpoints.ROUTED_LOGIN; @@ -348,7 +340,7 @@ void loginEndpointActsAsExpected(String testName, URI loginUrl, RestAssuredConfi .post(loginUrl); response.then() - .statusCode(is(rc.value())); + .statusCode(is(rc.value())); if (loggedUser != null) { Cookie cookie = response.detailedCookie(COOKIE_NAME); @@ -359,12 +351,12 @@ void loginEndpointActsAsExpected(String testName, URI loginUrl, RestAssuredConfi @ParameterizedTest(name = "givenTextContentTypeWithBody {index} {0} ") @MethodSource("org.zowe.apiml.integration.authentication.providers.LoginTest#loginUrlsSource") - void givenTextContentTypeWithBody_thenUnsupportedMediaType(URI loginUrl) { + void givenTextContentTypeWithBody_thenUnsupportedMediaType(URI loginUrl) throws Exception { LoginRequest loginRequest = new LoginRequest(getUsername(), getPassword().toCharArray()); - + ObjectMapper mapper = new ObjectMapper(); given() .contentType(TEXT) - .body(loginRequest) + .body(mapper.writeValueAsString(loginRequest)) .when() .post(loginUrl) .then() From c3bab71eb2e8708b44c664ccce24744503373bf4 Mon Sep 17 00:00:00 2001 From: ac892247 Date: Fri, 17 Jul 2026 11:23:42 +0200 Subject: [PATCH 04/11] No body present: fall back to empty bytes without setting CACHED_BODY_ATTR. Using defaultIfEmpty (rather than switchIfEmpty) guarantees the chain is filtered exactly once. chain.filter(...) returns Mono, which completes without emitting a value, so a switchIfEmpty placed after it would always fire its fallback and invoke the downstream chain (and the controller) a second time. Signed-off-by: ac892247 --- .../org/zowe/apiml/filter/CachedBodyFilter.java | 14 ++++++-------- .../zowe/apiml/filter/CachedBodyFilterTest.java | 9 +++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apiml/src/main/java/org/zowe/apiml/filter/CachedBodyFilter.java b/apiml/src/main/java/org/zowe/apiml/filter/CachedBodyFilter.java index 66f0107ac9..46fa7b1201 100644 --- a/apiml/src/main/java/org/zowe/apiml/filter/CachedBodyFilter.java +++ b/apiml/src/main/java/org/zowe/apiml/filter/CachedBodyFilter.java @@ -39,11 +39,15 @@ public class CachedBodyFilter implements WebFilter { @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { return DataBufferUtils.join(exchange.getRequest().getBody()) - .flatMap(dataBuffer -> { + .map(dataBuffer -> { var bytes = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer); exchange.getAttributes().put(CACHED_BODY_ATTR, new String(bytes, StandardCharsets.UTF_8)); + return bytes; + }) + .defaultIfEmpty(new byte[0]) + .flatMap(bytes -> { var decoratedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) { @Override public Flux getBody() { @@ -51,13 +55,7 @@ public Flux getBody() { } }; return chain.filter(exchange.mutate().request(decoratedRequest).build()); - }) - .switchIfEmpty(chain.filter(exchange.mutate().request(new ServerHttpRequestDecorator(exchange.getRequest()) { - @Override - public Flux getBody() { - return Flux.just(exchange.getResponse().bufferFactory().wrap(new byte[]{})); - } - }).build())); + }); } } diff --git a/apiml/src/test/java/org/zowe/apiml/filter/CachedBodyFilterTest.java b/apiml/src/test/java/org/zowe/apiml/filter/CachedBodyFilterTest.java index 5c1de89630..b32c56821b 100644 --- a/apiml/src/test/java/org/zowe/apiml/filter/CachedBodyFilterTest.java +++ b/apiml/src/test/java/org/zowe/apiml/filter/CachedBodyFilterTest.java @@ -27,6 +27,8 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -55,6 +57,11 @@ void givenBody_thenBodyIsAvailableAndRequestReadable() { StepVerifier.create(filter.filter(exchange, chain)) .verifyComplete(); + // The downstream chain (and thus the controller) must run exactly once, even + // though chain.filter(...) returns Mono. Otherwise a request with a body + // is processed twice: once with the cached body and again with an empty body. + verify(chain, times(1)).filter(any()); + assertEquals("a readable body", exchange.getAttribute(CachedBodyFilter.CACHED_BODY_ATTR)); StepVerifier.create(DataBufferUtils.join(exchange.getRequest().getBody())) @@ -82,6 +89,8 @@ void givenEmptyBody_thenClean() { StepVerifier.create(filter.filter(exchange, chain)) .verifyComplete(); + verify(chain, times(1)).filter(any()); + assertNull(exchange.getAttribute(CachedBodyFilter.CACHED_BODY_ATTR)); } From b2c8947cb742068dd50af592e431af92e7222739 Mon Sep 17 00:00:00 2001 From: ac892247 Date: Mon, 20 Jul 2026 12:41:06 +0200 Subject: [PATCH 05/11] validate fetch site header Signed-off-by: ac892247 --- .../gateway/config/ConnectionsConfig.java | 16 ++ .../filters/security/SecFetchSiteFilter.java | 115 ++++++++++++ .../security/SecFetchSiteFilterTest.java | 170 ++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java create mode 100644 gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java index fcf9c2a336..b8c0a6330c 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java @@ -60,6 +60,7 @@ import org.zowe.apiml.constants.EurekaMetadataDefinition; import org.zowe.apiml.gateway.filters.proxyheaders.AdditionalRegistrationGatewayRegistry; import org.zowe.apiml.gateway.filters.proxyheaders.X509AndGwAwareXForwardedHeadersFilter; +import org.zowe.apiml.gateway.filters.security.SecFetchSiteFilter; import org.zowe.apiml.message.log.ApimlLogger; import org.zowe.apiml.message.yaml.YamlMessageServiceInstance; import org.zowe.apiml.product.eureka.EurekaServiceUrlUtils; @@ -116,6 +117,9 @@ public class ConnectionsConfig { @Value("#{T(org.springframework.util.StringUtils).hasText('${apiml.service.corsDefaultAllowedHeaders:}') ? '${apiml.service.corsDefaultAllowedHeaders:}' : '*'}") private String corsDefaultAllowedHeaders; + @Value("${apiml.security.csrf.validateFetchMetadata:true}") + private boolean validateFetchMetadata; + @Value("${apiml.service.hostname:localhost}") private String hostname; @@ -338,6 +342,18 @@ WebFilter corsWebFilter(ServiceCorsUpdater serviceCorsUpdater) { return new CorsWebFilter(serviceCorsUpdater.getUrlBasedCorsConfigurationSource()); } + /** + * Token-free CSRF protection for state-changing requests based on the {@code Sec-Fetch-Site} + * request header. Delegates the cross-origin allow decision to the same live CORS configuration + * source used by {@link #corsWebFilter(ServiceCorsUpdater)}, so Gateway defaults and per-service + * Eureka metadata origins are honored consistently. Can be disabled with + * {@code apiml.security.csrf.validateFetchMetadata=false}. + */ + @Bean + WebFilter secFetchSiteFilter(ServiceCorsUpdater serviceCorsUpdater) { + return new SecFetchSiteFilter(serviceCorsUpdater.getUrlBasedCorsConfigurationSource(), validateFetchMetadata); + } + public InstanceInfo create(EurekaInstanceConfig config) { LeaseInfo.Builder leaseInfoBuilder = LeaseInfo.Builder.newBuilder() .setRenewalIntervalInSecs(config.getLeaseRenewalIntervalInSeconds()) diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java new file mode 100644 index 0000000000..ea2ad8709b --- /dev/null +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java @@ -0,0 +1,115 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.filters.security; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsConfigurationSource; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.util.Set; + +/** + * Token-free CSRF protection based on the browser-supplied Fetch Metadata request headers + * (specifically {@code Sec-Fetch-Site}). This complements, and does not replace, the existing + * CORS handling and {@code SameSite} cookies. + *

+ * {@code Sec-Fetch-*} headers are set by the browser and cannot be overridden by page scripts + * (they are on the forbidden header name list), which is what makes them trustworthy for CSRF + * defense. The policy applied here is a Fetch Metadata Resource Isolation Policy scoped to + * state-changing requests: + *

+ * The CORS decision is delegated to the same live {@link CorsConfigurationSource} that backs the + * Gateway's CORS filter, so both the Gateway default origins and the per-service origins declared in + * Eureka registration metadata are honored automatically, including services that (de)register at runtime. + */ +@Slf4j +public class SecFetchSiteFilter implements WebFilter, Ordered { + + static final String SEC_FETCH_SITE_HEADER = "Sec-Fetch-Site"; + + private static final Set SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); + private static final Set ALLOWED_SITES = Set.of("same-origin", "none"); + + private final CorsConfigurationSource corsConfigurationSource; + private final boolean enabled; + + public SecFetchSiteFilter(CorsConfigurationSource corsConfigurationSource, boolean enabled) { + this.corsConfigurationSource = corsConfigurationSource; + this.enabled = enabled; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + if (!enabled || isAllowed(exchange)) { + return chain.filter(exchange); + } + + ServerHttpRequest request = exchange.getRequest(); + log.debug("Rejecting request as a potential CSRF attempt: method={}, path={}, origin={}, Sec-Fetch-Site={}", + request.getMethod(), request.getPath(), request.getHeaders().getOrigin(), + request.getHeaders().getFirst(SEC_FETCH_SITE_HEADER)); + exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN); + return exchange.getResponse().setComplete(); + } + + private boolean isAllowed(ServerWebExchange exchange) { + ServerHttpRequest request = exchange.getRequest(); + + // Safe (non-state-changing) methods are exempt: navigations, cross-origin reads, CORS preflight. + if (SAFE_METHODS.contains(request.getMethod())) { + return true; + } + + String site = request.getHeaders().getFirst(SEC_FETCH_SITE_HEADER); + // Header absent -> non-browser client or legacy browser: cannot be a CSRF vector. + if (site == null) { + return true; + } + // Same-origin and user-initiated (typed URL / bookmark) requests are trusted. + if (ALLOWED_SITES.contains(site)) { + return true; + } + // same-site / cross-site: allow only if the Origin is on the effective CORS allow-list. + return isAllowedByCors(exchange); + } + + private boolean isAllowedByCors(ServerWebExchange exchange) { + String origin = exchange.getRequest().getHeaders().getOrigin(); + if (origin == null) { + return false; + } + CorsConfiguration corsConfiguration = corsConfigurationSource.getCorsConfiguration(exchange); + return corsConfiguration != null && corsConfiguration.checkOrigin(origin) != null; + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } + +} diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java new file mode 100644 index 0000000000..c31c131662 --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java @@ -0,0 +1,170 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.filters.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsConfigurationSource; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SecFetchSiteFilterTest { + + private static final String ALLOWED_ORIGIN = "https://trusted.example.com"; + private static final String OTHER_ORIGIN = "https://evil.example.com"; + + private CorsConfigurationSource corsConfigurationSource; + private AtomicBoolean chainCalled; + private WebFilterChain chain; + + @BeforeEach + void setUp() { + corsConfigurationSource = mock(CorsConfigurationSource.class); + chainCalled = new AtomicBoolean(false); + chain = exchange -> { + chainCalled.set(true); + return Mono.empty(); + }; + } + + private SecFetchSiteFilter filter(boolean enabled) { + return new SecFetchSiteFilter(corsConfigurationSource, enabled); + } + + private MockServerWebExchange exchange(HttpMethod method, String secFetchSite, String origin) { + MockServerHttpRequest.BaseBuilder builder = MockServerHttpRequest.method(method, "/service/api/v1/foo"); + if (secFetchSite != null) { + builder.header(SecFetchSiteFilter.SEC_FETCH_SITE_HEADER, secFetchSite); + } + if (origin != null) { + builder.header(HttpHeaders.ORIGIN, origin); + } + return MockServerWebExchange.from(builder.build()); + } + + private void mockCorsAllowing(String... origins) { + CorsConfiguration configuration = new CorsConfiguration(); + for (String origin : origins) { + configuration.addAllowedOrigin(origin); + } + when(corsConfigurationSource.getCorsConfiguration(any())).thenReturn(configuration); + } + + private void assertAllowed(MockServerWebExchange exchange, SecFetchSiteFilter filter) { + filter.filter(exchange, chain).block(); + assertTrue(chainCalled.get(), "expected the filter chain to continue"); + assertNull(exchange.getResponse().getStatusCode(), "expected no status to be set by the filter"); + } + + private void assertRejected(MockServerWebExchange exchange, SecFetchSiteFilter filter) { + filter.filter(exchange, chain).block(); + assertFalse(chainCalled.get(), "expected the filter chain to be short-circuited"); + assertEquals(HttpStatus.FORBIDDEN, exchange.getResponse().getStatusCode()); + } + + @Nested + class WhenEnabled { + + @Test + void givenNoSecFetchSiteHeader_thenAllowed() { + assertAllowed(exchange(HttpMethod.POST, null, null), filter(true)); + } + + @Test + void givenSameOrigin_thenAllowed() { + assertAllowed(exchange(HttpMethod.POST, "same-origin", null), filter(true)); + } + + @Test + void givenNone_thenAllowed() { + assertAllowed(exchange(HttpMethod.POST, "none", null), filter(true)); + } + + @Test + void givenSafeMethodCrossSite_thenAllowed() { + assertAllowed(exchange(HttpMethod.GET, "cross-site", OTHER_ORIGIN), filter(true)); + } + + @Test + void givenPreflightOptionsCrossSite_thenAllowed() { + assertAllowed(exchange(HttpMethod.OPTIONS, "cross-site", OTHER_ORIGIN), filter(true)); + } + + @Test + void givenCrossSiteFromCorsAllowedOrigin_thenAllowed() { + mockCorsAllowing(ALLOWED_ORIGIN); + assertAllowed(exchange(HttpMethod.POST, "cross-site", ALLOWED_ORIGIN), filter(true)); + } + + @Test + void givenCrossSiteFromNonAllowedOrigin_thenRejected() { + mockCorsAllowing(ALLOWED_ORIGIN); + assertRejected(exchange(HttpMethod.POST, "cross-site", OTHER_ORIGIN), filter(true)); + } + + @Test + void givenSameSiteFromCorsAllowedOrigin_thenAllowed() { + mockCorsAllowing(ALLOWED_ORIGIN); + assertAllowed(exchange(HttpMethod.POST, "same-site", ALLOWED_ORIGIN), filter(true)); + } + + @Test + void givenSameSiteFromNonAllowedOrigin_thenRejected() { + mockCorsAllowing(ALLOWED_ORIGIN); + assertRejected(exchange(HttpMethod.POST, "same-site", OTHER_ORIGIN), filter(true)); + } + + @Test + void givenCrossSiteWithoutOriginHeader_thenRejected() { + mockCorsAllowing(ALLOWED_ORIGIN); + assertRejected(exchange(HttpMethod.POST, "cross-site", null), filter(true)); + } + + @Test + void givenCrossSiteAndNoCorsConfigForPath_thenRejected() { + when(corsConfigurationSource.getCorsConfiguration(any())).thenReturn(null); + assertRejected(exchange(HttpMethod.POST, "cross-site", OTHER_ORIGIN), filter(true)); + } + } + + @Nested + class WhenDisabled { + + @Test + void givenCrossSiteFromNonAllowedOrigin_thenAllowed() { + assertAllowed(exchange(HttpMethod.POST, "cross-site", OTHER_ORIGIN), filter(false)); + } + } + +} From 0ca1beb95421ab87876fd249416c6a9fabbc5ff7 Mon Sep 17 00:00:00 2001 From: ac892247 Date: Mon, 20 Jul 2026 12:41:28 +0200 Subject: [PATCH 06/11] validate fetch site header Signed-off-by: ac892247 --- gateway-service/src/main/resources/application.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index f14de44560..7c09182023 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -114,6 +114,8 @@ apiml: externalUrl: ${apiml.service.scheme}://${apiml.service.hostname}:${apiml.service.port} security: headersToBeCleared: X-Certificate-Public,X-Certificate-DistinguishedName,X-Certificate-CommonName + csrf: + validateFetchMetadata: true ssl: nonStrictVerifySslCertificatesOfServices: false rauditx: From d0690eb6ac78459cfd3e78376251b4f95eab42b1 Mon Sep 17 00:00:00 2001 From: ac892247 Date: Mon, 20 Jul 2026 15:54:01 +0200 Subject: [PATCH 07/11] test cross origin requests Signed-off-by: ac892247 --- .../proxy/CsrfFetchMetadataTest.java | 101 ++++++++++++++++++ .../zowe/apiml/util/requests/Endpoints.java | 2 + 2 files changed, 103 insertions(+) create mode 100644 integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CsrfFetchMetadataTest.java diff --git a/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CsrfFetchMetadataTest.java b/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CsrfFetchMetadataTest.java new file mode 100644 index 0000000000..c351089dda --- /dev/null +++ b/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CsrfFetchMetadataTest.java @@ -0,0 +1,101 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.integration.proxy; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.zowe.apiml.util.categories.DiscoverableClientDependentTest; +import org.zowe.apiml.util.config.ItSslConfigFactory; +import org.zowe.apiml.util.config.SslContext; +import org.zowe.apiml.util.http.HttpRequestUtils; +import org.zowe.apiml.util.requests.Endpoints; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +/** + * Verifies the token-free CSRF protection driven by the {@code Sec-Fetch-Site} request header. + *

+ * A state-changing (POST) request marked by the browser as {@code cross-site} is rejected with 403 + * unless its {@code Origin} is permitted by the target service's CORS configuration. The success and + * failure cases send an identical cross-site request (same Origin, same {@code Sec-Fetch-Site} + * header, same {@code /status-code} endpoint) and differ only in the target service, so the outcome is + * attributable solely to the per-service CORS origins declared in the Eureka registration metadata + * (see api-defs/staticclient.yml): + *

    + *
  • {@code staticclient} declares {@code corsAllowedOrigins: https://localhost2:10010} -> request succeeds (200)
  • + *
  • {@code staticclient2} declares no CORS metadata -> request rejected (403)
  • + *
+ */ +@DiscoverableClientDependentTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CsrfFetchMetadataTest { + + private static final String SEC_FETCH_SITE_HEADER = "Sec-Fetch-Site"; + private static final String CROSS_SITE = "cross-site"; + // Origin explicitly whitelisted for the staticclient service via its Eureka metadata + private static final String ALLOWED_ORIGIN = "https://discoverable-client:10012"; + private static final String NOT_ALLOWED_ORIGIN = "https://localhost:10012"; + + private static final int OK = 200; + private static final int FORBIDDEN = 403; + + @BeforeAll + void init() throws Exception { + RestAssured.useRelaxedHTTPSValidation(); + SslContext.prepareSslAuthentication(ItSslConfigFactory.integrationTests()); + } + + @Test + void givenCrossSiteRequest_whenServiceAllowsOriginViaMetadata_thenRequestSucceeds() { + given() + .log().all() + .header("Origin", ALLOWED_ORIGIN) + .header(SEC_FETCH_SITE_HEADER, CROSS_SITE) + .when() + .post(HttpRequestUtils.getUriFromGateway(Endpoints.STATIC_CLIENT_1_STATUS_CODE)) + .then() + .log().all() + .statusCode(OK); + } + + @Test + void givenCrossSiteRequest_whenServiceDoesNotAllowOrigin_thenRejectedAsForbidden() { + // Identical to the succeeding request above except for the target service, which does not + // whitelist this Origin, so the CSRF filter rejects it before it is routed downstream. + given() + .log().all() + .header("Origin", NOT_ALLOWED_ORIGIN) + .header(SEC_FETCH_SITE_HEADER, CROSS_SITE) + .when() + .post(HttpRequestUtils.getUriFromGateway(Endpoints.STATIC_CLIENT_2_STATUS_CODE)) + .then() + .log().all() + .statusCode(FORBIDDEN); + } + + @Test + void givenNonBrowserRequest_whenNoFetchMetadataHeader_thenNotRejected() { + // No Sec-Fetch-Site header (a non-browser client) must never be blocked, even for a service + // that would reject a cross-site browser request. Confirms the 403 above is driven by the header. + given() + .log().all() + .when() + .post(HttpRequestUtils.getUriFromGateway(Endpoints.STATIC_CLIENT_2_STATUS_CODE)) + .then() + .log().all() + .statusCode(not(equalTo(FORBIDDEN))); + } + +} diff --git a/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java b/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java index de11c1a33b..1aa7bdb59f 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java +++ b/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java @@ -73,6 +73,8 @@ public class Endpoints { public static final String ZOWE_JWT_REQUEST = "/zowejwt/api/v1/request"; public static final String STATIC_CLIENT_2_REQUEST = "/staticclient2/api/v1/request"; public static final String STATIC_CLIENT_1_REQUEST = "/staticclient/api/v1/request"; + public static final String STATIC_CLIENT_1_STATUS_CODE = "/staticclient/api/v1/status-code"; + public static final String STATIC_CLIENT_2_STATUS_CODE = "/staticclient2/api/v1/status-code"; public static final String DISCOVERABLE_CLIENT_CONTAINER_ENDPOINT = "/apicatalog/api/v1/containers/cademoapps"; public static final String DISCOVERABLE_CLIENT_API_DOC_ENDPOINT = "/apicatalog/api/v1/apidoc/discoverableclient/zowe.apiml.discoverableclient.rest v1.0.0"; From 6c8ca33fb32d6adc5e8414142aed4b418c53cd79 Mon Sep 17 00:00:00 2001 From: ac892247 Date: Tue, 21 Jul 2026 14:43:00 +0200 Subject: [PATCH 08/11] continue only when CORS enabled Signed-off-by: ac892247 --- apiml/src/main/resources/application.yml | 2 + .../gateway/config/ConnectionsConfig.java | 15 +- .../apiml/gateway/config/RoutingConfig.java | 5 +- ...derIfNotCrossSiteGatewayFilterFactory.java | 75 +++++++++ .../filters/security/SecFetchSiteFilter.java | 100 ++++++------ .../src/main/resources/application.yml | 2 +- .../gateway/config/RoutingConfigTest.java | 54 +++++++ ...fNotCrossSiteGatewayFilterFactoryTest.java | 87 +++++++++++ .../security/SecFetchSiteFilterTest.java | 142 +++++++----------- 9 files changed, 336 insertions(+), 146 deletions(-) create mode 100644 gateway-service/src/main/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory.java create mode 100644 gateway-service/src/test/java/org/zowe/apiml/gateway/config/RoutingConfigTest.java create mode 100644 gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactoryTest.java diff --git a/apiml/src/main/resources/application.yml b/apiml/src/main/resources/application.yml index 5c5fe501da..3fc2ad6ed5 100644 --- a/apiml/src/main/resources/application.yml +++ b/apiml/src/main/resources/application.yml @@ -205,6 +205,8 @@ apiml: securePortEnabled: true externalUrl: ${apiml.service.scheme}://${apiml.service.hostname}:${apiml.service.port} security: + csrf: + preserveOriginForCrossSite: true headersToBeCleared: X-Certificate-Public,X-Certificate-DistinguishedName,X-Certificate-CommonName ssl: nonStrictVerifySslCertificatesOfServices: false diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java index b8c0a6330c..de1dd870bb 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java @@ -31,6 +31,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.cloud.circuitbreaker.resilience4j.ReactiveResilience4JCircuitBreakerFactory; import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder; @@ -117,9 +118,6 @@ public class ConnectionsConfig { @Value("#{T(org.springframework.util.StringUtils).hasText('${apiml.service.corsDefaultAllowedHeaders:}') ? '${apiml.service.corsDefaultAllowedHeaders:}' : '*'}") private String corsDefaultAllowedHeaders; - @Value("${apiml.security.csrf.validateFetchMetadata:true}") - private boolean validateFetchMetadata; - @Value("${apiml.service.hostname:localhost}") private String hostname; @@ -344,14 +342,13 @@ WebFilter corsWebFilter(ServiceCorsUpdater serviceCorsUpdater) { /** * Token-free CSRF protection for state-changing requests based on the {@code Sec-Fetch-Site} - * request header. Delegates the cross-origin allow decision to the same live CORS configuration - * source used by {@link #corsWebFilter(ServiceCorsUpdater)}, so Gateway defaults and per-service - * Eureka metadata origins are honored consistently. Can be disabled with - * {@code apiml.security.csrf.validateFetchMetadata=false}. + * request header. When CORS is enabled the Origin is validated by the CORS filter + * ({@link #corsWebFilter(ServiceCorsUpdater)}), so this filter defers to it; when CORS is disabled + * it rejects cross-site requests itself. */ @Bean - WebFilter secFetchSiteFilter(ServiceCorsUpdater serviceCorsUpdater) { - return new SecFetchSiteFilter(serviceCorsUpdater.getUrlBasedCorsConfigurationSource(), validateFetchMetadata); + WebFilter secFetchSiteFilter() { + return new SecFetchSiteFilter(gatewayCorsEnabled); } public InstanceInfo create(EurekaInstanceConfig config) { diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/RoutingConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/RoutingConfig.java index 7f92ade75b..1936139e7a 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/RoutingConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/RoutingConfig.java @@ -57,7 +57,10 @@ public List commonNoRetryFilters() { for (String headerName : ignoredHeadersWhenCorsEnabled.split(",")) { FilterDefinition removeHeaders = new FilterDefinition(); - removeHeaders.setName("RemoveRequestHeader"); + // When preserving Origin for cross-site requests, use the conditional filter that keeps the + // header for Sec-Fetch-Site: cross-site so the southbound service can make its own CSRF + // decision; otherwise fall back to the unconditional built-in removal. + removeHeaders.setName("RemoveRequestHeaderIfNotCrossSite"); Map args = new HashMap<>(); args.put("name", headerName); removeHeaders.setArgs(args); diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory.java new file mode 100644 index 0000000000..01837ef5f7 --- /dev/null +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory.java @@ -0,0 +1,75 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.filters; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Removes the configured request header before the request is routed to the southbound service, + * unless the request is a cross-site browser request (i.e. {@code Sec-Fetch-Site: cross-site}). + *

+ * The Gateway strips CORS request headers (notably {@code Origin}) so that it remains the sole CORS + * terminator and southbound services do not perform their own CORS processing. Only cross-site requests + * carry a CSRF risk that a southbound service may want to inspect, so for those the header is preserved, + * allowing the service to examine {@code Origin} together with {@code Sec-Fetch-Site} and make its own + * decision. Every other request (same-origin, same-site, none, or non-browser without the header) keeps + * the previous behavior of having the header removed. + */ +@Component +public class RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory + extends AbstractGatewayFilterFactory { + + static final String SEC_FETCH_SITE_HEADER = "Sec-Fetch-Site"; + static final String CROSS_SITE = "cross-site"; + + @Value("${apiml.security.csrf.preserveOriginForCrossSite:true}") + private boolean preserveOrigin; + + public RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory() { + super(Config.class); + } + + @Override + public List shortcutFieldOrder() { + return List.of("name"); + } + + @Override + public GatewayFilter apply(Config config) { + return (exchange, chain) -> { + ServerHttpRequest request = exchange.getRequest(); + if (preserveOrigin && CROSS_SITE.equals(request.getHeaders().getFirst(SEC_FETCH_SITE_HEADER))) { + // Cross-site browser request: keep the header so the southbound service can inspect it. + return chain.filter(exchange); + } + + ServerHttpRequest mutatedRequest = request.mutate() + .headers(headers -> headers.remove(config.getName())) + .build(); + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + }; + } + + @Getter + @Setter + public static class Config { + private String name; + } + +} diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java index ea2ad8709b..3c40abc223 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java @@ -12,16 +12,19 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.core.Ordered; +import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.server.reactive.ServerHttpRequest; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.reactive.CorsConfigurationSource; +import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; +import java.nio.charset.StandardCharsets; +import java.util.Locale; import java.util.Set; /** @@ -29,82 +32,83 @@ * (specifically {@code Sec-Fetch-Site}). This complements, and does not replace, the existing * CORS handling and {@code SameSite} cookies. *

- * {@code Sec-Fetch-*} headers are set by the browser and cannot be overridden by page scripts - * (they are on the forbidden header name list), which is what makes them trustworthy for CSRF - * defense. The policy applied here is a Fetch Metadata Resource Isolation Policy scoped to - * state-changing requests: + * This is the reactive (Gateway edge) counterpart of the servlet {@code SecFetchSiteFilter} enforced + * by southbound services; both apply the same Fetch Metadata Resource Isolation Policy so a request is + * judged consistently at both tiers: *

    - *
  • Safe methods (GET/HEAD/OPTIONS) are exempt - this covers top-level navigations and the - * CORS preflight (OPTIONS), and safe methods must not change state by HTTP semantics.
  • - *
  • A missing {@code Sec-Fetch-Site} header means a non-browser client (CLI, service-to-service) - * or a legacy browser - such a caller cannot be a CSRF vector, so the request is allowed.
  • - *
  • {@code same-origin} and {@code none} (user-initiated, e.g. typed URL or bookmark) are allowed.
  • - *
  • {@code same-site} and {@code cross-site} are allowed only when the request's {@code Origin} - * is permitted by the effective CORS configuration. This keeps the filter consistent with CORS: - * it becomes the enforcement arm of the same allow-list CORS only declares.
  • + *
  • A missing {@code Sec-Fetch-Site} header (non-browser/legacy client) or a value of + * {@code same-origin} / {@code same-site} / {@code none} continues.
  • + *
  • Otherwise ({@code cross-site} or any other value): when CORS is enabled the request's + * {@code Origin} is validated (and rejected if not permitted) by the CORS {@code DefaultCorsProcessor}, + * so the decision is deferred to CORS; when CORS is disabled only a safe top-level navigation is + * allowed and everything else is rejected with 403.
  • *
- * The CORS decision is delegated to the same live {@link CorsConfigurationSource} that backs the - * Gateway's CORS filter, so both the Gateway default origins and the per-service origins declared in - * Eureka registration metadata are honored automatically, including services that (de)register at runtime. */ @Slf4j public class SecFetchSiteFilter implements WebFilter, Ordered { static final String SEC_FETCH_SITE_HEADER = "Sec-Fetch-Site"; + static final String SEC_FETCH_MODE_HEADER = "Sec-Fetch-Mode"; + static final String SEC_FETCH_DEST_HEADER = "Sec-Fetch-Dest"; - private static final Set SAFE_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); - private static final Set ALLOWED_SITES = Set.of("same-origin", "none"); + private static final Set SAFE_SEC_FETCH_SITE_VALUES = Set.of("same-origin", "same-site", "none"); + private static final String NAVIGATE_MODE = "navigate"; + private static final Set SAFE_NAVIGATION_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); + private static final Set UNSAFE_NAVIGATION_DESTINATIONS = Set.of("object", "embed"); + private static final String REJECTION_MESSAGE = "Invalid CORS request"; - private final CorsConfigurationSource corsConfigurationSource; - private final boolean enabled; + private final boolean corsEnabled; - public SecFetchSiteFilter(CorsConfigurationSource corsConfigurationSource, boolean enabled) { - this.corsConfigurationSource = corsConfigurationSource; - this.enabled = enabled; + public SecFetchSiteFilter(boolean corsEnabled) { + this.corsEnabled = corsEnabled; } @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - if (!enabled || isAllowed(exchange)) { + if (isAllowed(exchange)) { return chain.filter(exchange); } ServerHttpRequest request = exchange.getRequest(); - log.debug("Rejecting request as a potential CSRF attempt: method={}, path={}, origin={}, Sec-Fetch-Site={}", - request.getMethod(), request.getPath(), request.getHeaders().getOrigin(), - request.getHeaders().getFirst(SEC_FETCH_SITE_HEADER)); - exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN); - return exchange.getResponse().setComplete(); + log.debug("Blocked cross-site {} {} - Sec-Fetch-Site={}, CORS is not enabled", + request.getMethod(), request.getPath(), request.getHeaders().getFirst(SEC_FETCH_SITE_HEADER)); + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.FORBIDDEN); + response.getHeaders().setContentType(MediaType.TEXT_PLAIN); + DataBuffer body = response.bufferFactory().wrap(REJECTION_MESSAGE.getBytes(StandardCharsets.UTF_8)); + return response.writeWith(Mono.just(body)); } private boolean isAllowed(ServerWebExchange exchange) { ServerHttpRequest request = exchange.getRequest(); + String secFetchSite = request.getHeaders().getFirst(SEC_FETCH_SITE_HEADER); - // Safe (non-state-changing) methods are exempt: navigations, cross-origin reads, CORS preflight. - if (SAFE_METHODS.contains(request.getMethod())) { + // Absent header (non-browser/legacy client) or same-origin/same-site/none: continue. + if (secFetchSite == null || SAFE_SEC_FETCH_SITE_VALUES.contains(secFetchSite.toLowerCase(Locale.ROOT))) { return true; } - String site = request.getHeaders().getFirst(SEC_FETCH_SITE_HEADER); - // Header absent -> non-browser client or legacy browser: cannot be a CSRF vector. - if (site == null) { - return true; - } - // Same-origin and user-initiated (typed URL / bookmark) requests are trusted. - if (ALLOWED_SITES.contains(site)) { - return true; - } - // same-site / cross-site: allow only if the Origin is on the effective CORS allow-list. - return isAllowedByCors(exchange); + // Cross-site (or any other value): when CORS is enabled the Origin is validated by the CORS + // DefaultCorsProcessor, so defer to it; otherwise allow only a safe top-level navigation. + return corsEnabled || isSafeTopLevelNavigation(request); } - private boolean isAllowedByCors(ServerWebExchange exchange) { - String origin = exchange.getRequest().getHeaders().getOrigin(); - if (origin == null) { + /** + * A safe top-level navigation per the Fetch Metadata Resource Isolation Policy: a + * {@code Sec-Fetch-Mode: navigate} request using a safe method that is not being loaded into an + * {@code } or {@code }. Such navigations cannot read the response cross-origin + * and, being read-only, cannot change server state, so they are allowed even when cross-site. + */ + private boolean isSafeTopLevelNavigation(ServerHttpRequest request) { + String mode = request.getHeaders().getFirst(SEC_FETCH_MODE_HEADER); + if (!NAVIGATE_MODE.equalsIgnoreCase(mode)) { + return false; + } + if (!SAFE_NAVIGATION_METHODS.contains(request.getMethod())) { return false; } - CorsConfiguration corsConfiguration = corsConfigurationSource.getCorsConfiguration(exchange); - return corsConfiguration != null && corsConfiguration.checkOrigin(origin) != null; + String dest = request.getHeaders().getFirst(SEC_FETCH_DEST_HEADER); + return dest == null || !UNSAFE_NAVIGATION_DESTINATIONS.contains(dest.toLowerCase(Locale.ROOT)); } @Override diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index 7c09182023..95c5da1855 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -115,7 +115,7 @@ apiml: security: headersToBeCleared: X-Certificate-Public,X-Certificate-DistinguishedName,X-Certificate-CommonName csrf: - validateFetchMetadata: true + preserveOriginForCrossSite: true ssl: nonStrictVerifySslCertificatesOfServices: false rauditx: diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/RoutingConfigTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/RoutingConfigTest.java new file mode 100644 index 0000000000..96385bee3b --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/RoutingConfigTest.java @@ -0,0 +1,54 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.gateway.filter.FilterDefinition; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RoutingConfigTest { + + private static final String CONDITIONAL_REMOVAL = "RemoveRequestHeaderIfNotCrossSite"; + private static final String UNCONDITIONAL_REMOVAL = "RemoveRequestHeader"; + + private List commonNoRetryFilters(boolean preserveOriginForCrossSite) { + RoutingConfig routingConfig = new RoutingConfig(); + ReflectionTestUtils.setField(routingConfig, "ignoredHeadersWhenCorsEnabled", "Origin"); + ReflectionTestUtils.setField(routingConfig, "acceptForwardedCert", false); + ReflectionTestUtils.setField(routingConfig, "allowEncodedSlashes", true); + ReflectionTestUtils.setField(routingConfig, "preserveOriginForCrossSite", preserveOriginForCrossSite); + return routingConfig.commonNoRetryFilters(); + } + + private boolean hasFilterNamed(List filters, String name) { + return filters.stream().anyMatch(filter -> name.equals(filter.getName())); + } + + @Test + void givenPreserveOriginEnabled_thenConditionalRemovalFilterIsUsed() { + List filters = commonNoRetryFilters(true); + assertTrue(hasFilterNamed(filters, CONDITIONAL_REMOVAL)); + assertFalse(hasFilterNamed(filters, UNCONDITIONAL_REMOVAL)); + } + + @Test + void givenPreserveOriginDisabled_thenUnconditionalRemovalFilterIsUsed() { + List filters = commonNoRetryFilters(false); + assertTrue(hasFilterNamed(filters, UNCONDITIONAL_REMOVAL)); + assertFalse(hasFilterNamed(filters, CONDITIONAL_REMOVAL)); + } + +} diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactoryTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactoryTest.java new file mode 100644 index 0000000000..26566d86f5 --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactoryTest.java @@ -0,0 +1,87 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.filters; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactoryTest { + + private static final String ORIGIN = "https://trusted.example.com"; + + private final RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory factory = + new RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory(); + + private ServerWebExchange runFilter(MockServerHttpRequest request) { + var config = new RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory.Config(); + config.setName(HttpHeaders.ORIGIN); + GatewayFilter filter = factory.apply(config); + + AtomicReference captured = new AtomicReference<>(); + GatewayFilterChain chain = ex -> { + captured.set(ex); + return Mono.empty(); + }; + filter.filter(MockServerWebExchange.from(request), chain).block(); + return captured.get(); + } + + @Test + void givenCrossSiteRequest_thenConfiguredHeaderPreserved() { + MockServerHttpRequest request = MockServerHttpRequest.post("/service/api/v1/foo") + .header(HttpHeaders.ORIGIN, ORIGIN) + .header(RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory.SEC_FETCH_SITE_HEADER, + RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory.CROSS_SITE) + .build(); + + ServerWebExchange result = runFilter(request); + + assertTrue(result.getRequest().getHeaders().containsKey(HttpHeaders.ORIGIN)); + } + + @ParameterizedTest + @ValueSource(strings = {"same-origin", "same-site", "none"}) + void givenNonCrossSiteRequest_thenConfiguredHeaderRemoved(String secFetchSite) { + MockServerHttpRequest request = MockServerHttpRequest.post("/service/api/v1/foo") + .header(HttpHeaders.ORIGIN, ORIGIN) + .header(RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory.SEC_FETCH_SITE_HEADER, secFetchSite) + .build(); + + ServerWebExchange result = runFilter(request); + + assertFalse(result.getRequest().getHeaders().containsKey(HttpHeaders.ORIGIN)); + } + + @Test + void givenNoSecFetchSiteHeader_thenConfiguredHeaderRemoved() { + MockServerHttpRequest request = MockServerHttpRequest.post("/service/api/v1/foo") + .header(HttpHeaders.ORIGIN, ORIGIN) + .build(); + + ServerWebExchange result = runFilter(request); + + assertFalse(result.getRequest().getHeaders().containsKey(HttpHeaders.ORIGIN)); + } + +} diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java index c31c131662..c826ef5790 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java @@ -10,20 +10,15 @@ package org.zowe.apiml.gateway.filters.security; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.reactive.CorsConfigurationSource; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; @@ -33,137 +28,110 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) class SecFetchSiteFilterTest { - private static final String ALLOWED_ORIGIN = "https://trusted.example.com"; - private static final String OTHER_ORIGIN = "https://evil.example.com"; + private static final String ORIGIN = "https://evil.example.com"; - private CorsConfigurationSource corsConfigurationSource; - private AtomicBoolean chainCalled; - private WebFilterChain chain; - - @BeforeEach - void setUp() { - corsConfigurationSource = mock(CorsConfigurationSource.class); - chainCalled = new AtomicBoolean(false); - chain = exchange -> { - chainCalled.set(true); - return Mono.empty(); - }; - } - - private SecFetchSiteFilter filter(boolean enabled) { - return new SecFetchSiteFilter(corsConfigurationSource, enabled); - } - - private MockServerWebExchange exchange(HttpMethod method, String secFetchSite, String origin) { - MockServerHttpRequest.BaseBuilder builder = MockServerHttpRequest.method(method, "/service/api/v1/foo"); - if (secFetchSite != null) { - builder.header(SecFetchSiteFilter.SEC_FETCH_SITE_HEADER, secFetchSite); + private MockServerWebExchange exchange(HttpMethod method, String site, String mode, String dest) { + MockServerHttpRequest.BaseBuilder builder = MockServerHttpRequest.method(method, "/service/api/v1/foo") + .header(HttpHeaders.ORIGIN, ORIGIN); + if (site != null) { + builder.header(SecFetchSiteFilter.SEC_FETCH_SITE_HEADER, site); } - if (origin != null) { - builder.header(HttpHeaders.ORIGIN, origin); + if (mode != null) { + builder.header(SecFetchSiteFilter.SEC_FETCH_MODE_HEADER, mode); } - return MockServerWebExchange.from(builder.build()); - } - - private void mockCorsAllowing(String... origins) { - CorsConfiguration configuration = new CorsConfiguration(); - for (String origin : origins) { - configuration.addAllowedOrigin(origin); + if (dest != null) { + builder.header(SecFetchSiteFilter.SEC_FETCH_DEST_HEADER, dest); } - when(corsConfigurationSource.getCorsConfiguration(any())).thenReturn(configuration); + return MockServerWebExchange.from(builder.build()); } private void assertAllowed(MockServerWebExchange exchange, SecFetchSiteFilter filter) { + AtomicBoolean chainCalled = new AtomicBoolean(false); + WebFilterChain chain = ex -> { + chainCalled.set(true); + return Mono.empty(); + }; filter.filter(exchange, chain).block(); assertTrue(chainCalled.get(), "expected the filter chain to continue"); assertNull(exchange.getResponse().getStatusCode(), "expected no status to be set by the filter"); } private void assertRejected(MockServerWebExchange exchange, SecFetchSiteFilter filter) { + AtomicBoolean chainCalled = new AtomicBoolean(false); + WebFilterChain chain = ex -> { + chainCalled.set(true); + return Mono.empty(); + }; filter.filter(exchange, chain).block(); assertFalse(chainCalled.get(), "expected the filter chain to be short-circuited"); assertEquals(HttpStatus.FORBIDDEN, exchange.getResponse().getStatusCode()); + assertEquals("Invalid CORS request", exchange.getResponse().getBodyAsString().block()); } @Nested - class WhenEnabled { + class WhenCorsEnabled { - @Test - void givenNoSecFetchSiteHeader_thenAllowed() { - assertAllowed(exchange(HttpMethod.POST, null, null), filter(true)); - } + private final SecFetchSiteFilter filter = new SecFetchSiteFilter(true); - @Test - void givenSameOrigin_thenAllowed() { - assertAllowed(exchange(HttpMethod.POST, "same-origin", null), filter(true)); + @ParameterizedTest + @ValueSource(strings = {"same-origin", "same-site", "none", "cross-site"}) + void givenAnySite_thenAllowedAndDeferredToCors(String site) { + assertAllowed(exchange(HttpMethod.POST, site, null, null), filter); } @Test - void givenNone_thenAllowed() { - assertAllowed(exchange(HttpMethod.POST, "none", null), filter(true)); + void givenNoSecFetchSiteHeader_thenAllowed() { + assertAllowed(exchange(HttpMethod.POST, null, null, null), filter); } + } - @Test - void givenSafeMethodCrossSite_thenAllowed() { - assertAllowed(exchange(HttpMethod.GET, "cross-site", OTHER_ORIGIN), filter(true)); - } + @Nested + class WhenCorsDisabled { + + private final SecFetchSiteFilter filter = new SecFetchSiteFilter(false); @Test - void givenPreflightOptionsCrossSite_thenAllowed() { - assertAllowed(exchange(HttpMethod.OPTIONS, "cross-site", OTHER_ORIGIN), filter(true)); + void givenNoSecFetchSiteHeader_thenAllowed() { + assertAllowed(exchange(HttpMethod.POST, null, null, null), filter); } - @Test - void givenCrossSiteFromCorsAllowedOrigin_thenAllowed() { - mockCorsAllowing(ALLOWED_ORIGIN); - assertAllowed(exchange(HttpMethod.POST, "cross-site", ALLOWED_ORIGIN), filter(true)); + @ParameterizedTest + @ValueSource(strings = {"same-origin", "same-site", "none"}) + void givenSafeSite_thenAllowed(String site) { + assertAllowed(exchange(HttpMethod.POST, site, null, null), filter); } @Test - void givenCrossSiteFromNonAllowedOrigin_thenRejected() { - mockCorsAllowing(ALLOWED_ORIGIN); - assertRejected(exchange(HttpMethod.POST, "cross-site", OTHER_ORIGIN), filter(true)); + void givenCrossSiteStateChanging_thenRejected() { + assertRejected(exchange(HttpMethod.POST, "cross-site", null, null), filter); } @Test - void givenSameSiteFromCorsAllowedOrigin_thenAllowed() { - mockCorsAllowing(ALLOWED_ORIGIN); - assertAllowed(exchange(HttpMethod.POST, "same-site", ALLOWED_ORIGIN), filter(true)); + void givenCrossSiteNonNavigationGet_thenRejected() { + assertRejected(exchange(HttpMethod.GET, "cross-site", "cors", null), filter); } @Test - void givenSameSiteFromNonAllowedOrigin_thenRejected() { - mockCorsAllowing(ALLOWED_ORIGIN); - assertRejected(exchange(HttpMethod.POST, "same-site", OTHER_ORIGIN), filter(true)); + void givenCrossSiteSafeTopLevelNavigation_thenAllowed() { + assertAllowed(exchange(HttpMethod.GET, "cross-site", "navigate", "document"), filter); } @Test - void givenCrossSiteWithoutOriginHeader_thenRejected() { - mockCorsAllowing(ALLOWED_ORIGIN); - assertRejected(exchange(HttpMethod.POST, "cross-site", null), filter(true)); + void givenCrossSiteNavigationIntoObject_thenRejected() { + assertRejected(exchange(HttpMethod.GET, "cross-site", "navigate", "object"), filter); } @Test - void givenCrossSiteAndNoCorsConfigForPath_thenRejected() { - when(corsConfigurationSource.getCorsConfiguration(any())).thenReturn(null); - assertRejected(exchange(HttpMethod.POST, "cross-site", OTHER_ORIGIN), filter(true)); + void givenCrossSiteNavigationWithUnsafeMethod_thenRejected() { + assertRejected(exchange(HttpMethod.POST, "cross-site", "navigate", "document"), filter); } - } - - @Nested - class WhenDisabled { @Test - void givenCrossSiteFromNonAllowedOrigin_thenAllowed() { - assertAllowed(exchange(HttpMethod.POST, "cross-site", OTHER_ORIGIN), filter(false)); + void givenSecFetchSiteValueIsCaseInsensitive_thenAllowed() { + assertAllowed(exchange(HttpMethod.POST, "Same-Origin", null, null), filter); } } From 5f8f0e05c0f523219a0dde68f83d49ece8e301f5 Mon Sep 17 00:00:00 2001 From: ac892247 Date: Tue, 21 Jul 2026 14:50:07 +0200 Subject: [PATCH 09/11] remove import Signed-off-by: ac892247 --- .../gateway/config/ConnectionsConfig.java | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java index de1dd870bb..072de5799b 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java @@ -10,11 +10,7 @@ package org.zowe.apiml.gateway.config; -import com.netflix.appinfo.ApplicationInfoManager; -import com.netflix.appinfo.EurekaInstanceConfig; -import com.netflix.appinfo.HealthCheckHandler; -import com.netflix.appinfo.InstanceInfo; -import com.netflix.appinfo.LeaseInfo; +import com.netflix.appinfo.*; import com.netflix.discovery.EurekaClient; import com.netflix.discovery.EurekaClientConfig; import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; @@ -31,7 +27,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.config.BeanPostProcessor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.cloud.circuitbreaker.resilience4j.ReactiveResilience4JCircuitBreakerFactory; import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder; @@ -75,19 +70,11 @@ import java.net.MalformedURLException; import java.net.URL; import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import static org.springframework.cloud.netflix.eureka.EurekaClientConfigBean.DEFAULT_ZONE; -import static org.zowe.apiml.constants.EurekaMetadataDefinition.REGISTRATION_TYPE; -import static org.zowe.apiml.constants.EurekaMetadataDefinition.ROUTES; -import static org.zowe.apiml.constants.EurekaMetadataDefinition.ROUTES_GATEWAY_URL; -import static org.zowe.apiml.constants.EurekaMetadataDefinition.ROUTES_SERVICE_URL; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.*; //TODO this configuration should be removed as redundancy of the HttpConfig in the apiml-common @Configuration From 793f7f87f499cb9713bfb021d5b41c03f60b5208 Mon Sep 17 00:00:00 2001 From: ac892247 Date: Tue, 21 Jul 2026 15:39:31 +0200 Subject: [PATCH 10/11] update tests Signed-off-by: ac892247 --- .../gateway/config/RoutingConfigTest.java | 54 ------------------- ...fNotCrossSiteGatewayFilterFactoryTest.java | 2 + 2 files changed, 2 insertions(+), 54 deletions(-) delete mode 100644 gateway-service/src/test/java/org/zowe/apiml/gateway/config/RoutingConfigTest.java diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/RoutingConfigTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/RoutingConfigTest.java deleted file mode 100644 index 96385bee3b..0000000000 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/RoutingConfigTest.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * This program and the accompanying materials are made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, and is available at - * https://www.eclipse.org/legal/epl-v20.html - * - * SPDX-License-Identifier: EPL-2.0 - * - * Copyright Contributors to the Zowe Project. - */ - -package org.zowe.apiml.gateway.config; - -import org.junit.jupiter.api.Test; -import org.springframework.cloud.gateway.filter.FilterDefinition; -import org.springframework.test.util.ReflectionTestUtils; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class RoutingConfigTest { - - private static final String CONDITIONAL_REMOVAL = "RemoveRequestHeaderIfNotCrossSite"; - private static final String UNCONDITIONAL_REMOVAL = "RemoveRequestHeader"; - - private List commonNoRetryFilters(boolean preserveOriginForCrossSite) { - RoutingConfig routingConfig = new RoutingConfig(); - ReflectionTestUtils.setField(routingConfig, "ignoredHeadersWhenCorsEnabled", "Origin"); - ReflectionTestUtils.setField(routingConfig, "acceptForwardedCert", false); - ReflectionTestUtils.setField(routingConfig, "allowEncodedSlashes", true); - ReflectionTestUtils.setField(routingConfig, "preserveOriginForCrossSite", preserveOriginForCrossSite); - return routingConfig.commonNoRetryFilters(); - } - - private boolean hasFilterNamed(List filters, String name) { - return filters.stream().anyMatch(filter -> name.equals(filter.getName())); - } - - @Test - void givenPreserveOriginEnabled_thenConditionalRemovalFilterIsUsed() { - List filters = commonNoRetryFilters(true); - assertTrue(hasFilterNamed(filters, CONDITIONAL_REMOVAL)); - assertFalse(hasFilterNamed(filters, UNCONDITIONAL_REMOVAL)); - } - - @Test - void givenPreserveOriginDisabled_thenUnconditionalRemovalFilterIsUsed() { - List filters = commonNoRetryFilters(false); - assertTrue(hasFilterNamed(filters, UNCONDITIONAL_REMOVAL)); - assertFalse(hasFilterNamed(filters, CONDITIONAL_REMOVAL)); - } - -} diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactoryTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactoryTest.java index 26566d86f5..e1dd56c094 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactoryTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactoryTest.java @@ -18,6 +18,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @@ -49,6 +50,7 @@ private ServerWebExchange runFilter(MockServerHttpRequest request) { @Test void givenCrossSiteRequest_thenConfiguredHeaderPreserved() { + ReflectionTestUtils.setField(factory, "preserveOrigin", true); MockServerHttpRequest request = MockServerHttpRequest.post("/service/api/v1/foo") .header(HttpHeaders.ORIGIN, ORIGIN) .header(RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactory.SEC_FETCH_SITE_HEADER, From 6f40ab2083dae70b100d489d4ea5575b7448157f Mon Sep 17 00:00:00 2001 From: ac892247 Date: Wed, 22 Jul 2026 14:35:57 +0200 Subject: [PATCH 11/11] refactor Signed-off-by: ac892247 --- .../zowe/apiml/filter/ContentTypeFilter.java | 2 +- .../gateway/config/ConnectionsConfig.java | 4 +- .../filters/security/SecFetchSiteFilter.java | 33 +++--- .../security/SecFetchSiteFilterTest.java | 101 ++++++++++-------- 4 files changed, 73 insertions(+), 67 deletions(-) diff --git a/apiml/src/main/java/org/zowe/apiml/filter/ContentTypeFilter.java b/apiml/src/main/java/org/zowe/apiml/filter/ContentTypeFilter.java index 0dd968db41..3ffc2177da 100644 --- a/apiml/src/main/java/org/zowe/apiml/filter/ContentTypeFilter.java +++ b/apiml/src/main/java/org/zowe/apiml/filter/ContentTypeFilter.java @@ -44,7 +44,7 @@ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { @Override public int getOrder() { - return Ordered.HIGHEST_PRECEDENCE; + return Ordered.HIGHEST_PRECEDENCE + 1; } } diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java index 072de5799b..eb45ee6994 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java @@ -334,8 +334,8 @@ WebFilter corsWebFilter(ServiceCorsUpdater serviceCorsUpdater) { * it rejects cross-site requests itself. */ @Bean - WebFilter secFetchSiteFilter() { - return new SecFetchSiteFilter(gatewayCorsEnabled); + WebFilter secFetchSiteFilter(@Value("${security.secFetch.safeNavigationDestinations:#{null}}") Set safeNav) { + return new SecFetchSiteFilter(gatewayCorsEnabled, safeNav); } public InstanceInfo create(EurekaInstanceConfig config) { diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java index 3c40abc223..ec35cf12f4 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java @@ -13,7 +13,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.core.Ordered; import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.server.reactive.ServerHttpRequest; @@ -52,15 +51,16 @@ public class SecFetchSiteFilter implements WebFilter, Ordered { static final String SEC_FETCH_DEST_HEADER = "Sec-Fetch-Dest"; private static final Set SAFE_SEC_FETCH_SITE_VALUES = Set.of("same-origin", "same-site", "none"); - private static final String NAVIGATE_MODE = "navigate"; - private static final Set SAFE_NAVIGATION_METHODS = Set.of(HttpMethod.GET, HttpMethod.HEAD); - private static final Set UNSAFE_NAVIGATION_DESTINATIONS = Set.of("object", "embed"); + private static final Set SAFE_MODE = Set.of("navigate", "same-origin", "websocket"); + private static final String REJECTION_MESSAGE = "Invalid CORS request"; + private final Set safeNavigationDestinations; private final boolean corsEnabled; - public SecFetchSiteFilter(boolean corsEnabled) { + public SecFetchSiteFilter(boolean corsEnabled, Set safeNavigationDestinations) { this.corsEnabled = corsEnabled; + this.safeNavigationDestinations = safeNavigationDestinations; } @Override @@ -88,27 +88,20 @@ private boolean isAllowed(ServerWebExchange exchange) { return true; } - // Cross-site (or any other value): when CORS is enabled the Origin is validated by the CORS - // DefaultCorsProcessor, so defer to it; otherwise allow only a safe top-level navigation. return corsEnabled || isSafeTopLevelNavigation(request); } - /** - * A safe top-level navigation per the Fetch Metadata Resource Isolation Policy: a - * {@code Sec-Fetch-Mode: navigate} request using a safe method that is not being loaded into an - * {@code } or {@code }. Such navigations cannot read the response cross-origin - * and, being read-only, cannot change server state, so they are allowed even when cross-site. - */ private boolean isSafeTopLevelNavigation(ServerHttpRequest request) { - String mode = request.getHeaders().getFirst(SEC_FETCH_MODE_HEADER); - if (!NAVIGATE_MODE.equalsIgnoreCase(mode)) { - return false; - } - if (!SAFE_NAVIGATION_METHODS.contains(request.getMethod())) { + var mode = request.getHeaders().getFirst(SEC_FETCH_MODE_HEADER); + if (mode == null || !SAFE_MODE.contains(mode.toLowerCase(Locale.ROOT))) { return false; } - String dest = request.getHeaders().getFirst(SEC_FETCH_DEST_HEADER); - return dest == null || !UNSAFE_NAVIGATION_DESTINATIONS.contains(dest.toLowerCase(Locale.ROOT)); + var dest = request.getHeaders().getFirst(SEC_FETCH_DEST_HEADER); + return isSafeDest(dest); + } + + private boolean isSafeDest(String dest) { + return dest == null || (safeNavigationDestinations.isEmpty() || safeNavigationDestinations.contains(dest.toLowerCase(Locale.ROOT))); } @Override diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java index c826ef5790..a0d8ab3b90 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java @@ -10,9 +10,11 @@ package org.zowe.apiml.gateway.filters.security; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -22,6 +24,7 @@ import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -74,65 +77,75 @@ private void assertRejected(MockServerWebExchange exchange, SecFetchSiteFilter f @Nested class WhenCorsEnabled { - private final SecFetchSiteFilter filter = new SecFetchSiteFilter(true); + private SecFetchSiteFilter filter; - @ParameterizedTest - @ValueSource(strings = {"same-origin", "same-site", "none", "cross-site"}) - void givenAnySite_thenAllowedAndDeferredToCors(String site) { - assertAllowed(exchange(HttpMethod.POST, site, null, null), filter); + @BeforeEach + void setUp() { + filter = new SecFetchSiteFilter(true, Set.of()); } - @Test - void givenNoSecFetchSiteHeader_thenAllowed() { - assertAllowed(exchange(HttpMethod.POST, null, null, null), filter); + @ParameterizedTest + @ValueSource(strings = {"same-origin", "same-site", "none", "cross-site", "Same-Origin"}) + @NullSource + void givenAnySecFetchSiteHeader_thenAllowedAndDeferredToCors(String site) { + assertAllowed(exchange(HttpMethod.POST, site, null, null), filter); } } @Nested class WhenCorsDisabled { - private final SecFetchSiteFilter filter = new SecFetchSiteFilter(false); - - @Test - void givenNoSecFetchSiteHeader_thenAllowed() { - assertAllowed(exchange(HttpMethod.POST, null, null, null), filter); - } - - @ParameterizedTest - @ValueSource(strings = {"same-origin", "same-site", "none"}) - void givenSafeSite_thenAllowed(String site) { - assertAllowed(exchange(HttpMethod.POST, site, null, null), filter); - } - - @Test - void givenCrossSiteStateChanging_thenRejected() { - assertRejected(exchange(HttpMethod.POST, "cross-site", null, null), filter); - } - - @Test - void givenCrossSiteNonNavigationGet_thenRejected() { - assertRejected(exchange(HttpMethod.GET, "cross-site", "cors", null), filter); - } + private SecFetchSiteFilter filter; - @Test - void givenCrossSiteSafeTopLevelNavigation_thenAllowed() { - assertAllowed(exchange(HttpMethod.GET, "cross-site", "navigate", "document"), filter); + @BeforeEach + void setUp() { + filter = new SecFetchSiteFilter(false, Set.of()); } - @Test - void givenCrossSiteNavigationIntoObject_thenRejected() { - assertRejected(exchange(HttpMethod.GET, "cross-site", "navigate", "object"), filter); - } + @Nested + class GivenSafeSecFetchSiteHeader { - @Test - void givenCrossSiteNavigationWithUnsafeMethod_thenRejected() { - assertRejected(exchange(HttpMethod.POST, "cross-site", "navigate", "document"), filter); + @ParameterizedTest + @ValueSource(strings = {"same-origin", "same-site", "none", "Same-Origin", "SAME-SITE"}) + @NullSource + void thenAllowed(String site) { + assertAllowed(exchange(HttpMethod.POST, site, null, null), filter); + } } - @Test - void givenSecFetchSiteValueIsCaseInsensitive_thenAllowed() { - assertAllowed(exchange(HttpMethod.POST, "Same-Origin", null, null), filter); + @Nested + class GivenCrossSiteRequest { + + @Nested + class WhenNavigateMode { + + @ParameterizedTest + @ValueSource(strings = {"GET", "POST", "PUT", "DELETE"}) + void givenAllowedDestination_thenAllowed(String method) { + assertAllowed(exchange(HttpMethod.valueOf(method), "cross-site", "navigate", "document"), filter); + } + + @ParameterizedTest + @ValueSource(strings = {"object", "embed", "OBJECT", "EMBED"}) + void givenUnsafeDestination_thenRejected(String destination) { + assertRejected(exchange(HttpMethod.GET, "cross-site", "navigate", destination), new SecFetchSiteFilter(false, Set.of("iframe","frame"))); + } + + @Test + void givenNullDestination_thenAllowed() { + assertAllowed(exchange(HttpMethod.GET, "cross-site", "navigate", null), filter); + } + } + + @Nested + class WhenNonNavigateMode { + + @ParameterizedTest + @ValueSource(strings = {"cors", "no-cors"}) + void thenRejected(String mode) { + assertRejected(exchange(HttpMethod.POST, "cross-site", mode, null), filter); + } + } } } - }