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/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/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..3ffc2177da
--- /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 + 1;
+ }
+
+}
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/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));
}
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/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..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
@@ -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;
@@ -60,6 +56,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;
@@ -73,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
@@ -338,6 +327,17 @@ 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. 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(@Value("${security.secFetch.safeNavigationDestinations:#{null}}") Set safeNav) {
+ return new SecFetchSiteFilter(gatewayCorsEnabled, safeNav);
+ }
+
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/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
new file mode 100644
index 0000000000..ec35cf12f4
--- /dev/null
+++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilter.java
@@ -0,0 +1,112 @@
+/*
+ * 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.core.io.buffer.DataBuffer;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+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;
+
+/**
+ * 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.
+ *
+ * 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:
+ *
+ * - 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.
+ *
+ */
+@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_SEC_FETCH_SITE_VALUES = Set.of("same-origin", "same-site", "none");
+ 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, Set safeNavigationDestinations) {
+ this.corsEnabled = corsEnabled;
+ this.safeNavigationDestinations = safeNavigationDestinations;
+ }
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) {
+ if (isAllowed(exchange)) {
+ return chain.filter(exchange);
+ }
+
+ ServerHttpRequest request = exchange.getRequest();
+ 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);
+
+ // 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;
+ }
+
+ return corsEnabled || isSafeTopLevelNavigation(request);
+ }
+
+ private boolean isSafeTopLevelNavigation(ServerHttpRequest request) {
+ var mode = request.getHeaders().getFirst(SEC_FETCH_MODE_HEADER);
+ if (mode == null || !SAFE_MODE.contains(mode.toLowerCase(Locale.ROOT))) {
+ return false;
+ }
+ 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
+ public int getOrder() {
+ return Ordered.HIGHEST_PRECEDENCE;
+ }
+
+}
diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml
index f14de44560..95c5da1855 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:
+ preserveOriginForCrossSite: true
ssl:
nonStrictVerifySslCertificatesOfServices: false
rauditx:
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..e1dd56c094
--- /dev/null
+++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RemoveRequestHeaderIfNotCrossSiteGatewayFilterFactoryTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.test.util.ReflectionTestUtils;
+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() {
+ ReflectionTestUtils.setField(factory, "preserveOrigin", true);
+ 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
new file mode 100644
index 0000000000..a0d8ab3b90
--- /dev/null
+++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/security/SecFetchSiteFilterTest.java
@@ -0,0 +1,151 @@
+/*
+ * 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.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;
+import org.springframework.http.HttpStatus;
+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 java.util.Set;
+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;
+
+class SecFetchSiteFilterTest {
+
+ private static final String ORIGIN = "https://evil.example.com";
+
+ 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 (mode != null) {
+ builder.header(SecFetchSiteFilter.SEC_FETCH_MODE_HEADER, mode);
+ }
+ if (dest != null) {
+ builder.header(SecFetchSiteFilter.SEC_FETCH_DEST_HEADER, dest);
+ }
+ 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 WhenCorsEnabled {
+
+ private SecFetchSiteFilter filter;
+
+ @BeforeEach
+ void setUp() {
+ filter = new SecFetchSiteFilter(true, Set.of());
+ }
+
+ @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 SecFetchSiteFilter filter;
+
+ @BeforeEach
+ void setUp() {
+ filter = new SecFetchSiteFilter(false, Set.of());
+ }
+
+ @Nested
+ class GivenSafeSecFetchSiteHeader {
+
+ @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);
+ }
+ }
+
+ @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);
+ }
+ }
+ }
+ }
+}
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..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;
@@ -47,23 +48,16 @@
import static io.restassured.RestAssured.given;
import static io.restassured.http.ContentType.JSON;
-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.hamcrest.CoreMatchers.containsString;
-import static org.hamcrest.CoreMatchers.equalTo;
-import static org.hamcrest.CoreMatchers.startsWith;
+import static io.restassured.http.ContentType.TEXT;
+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;
@@ -346,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);
@@ -355,6 +349,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) throws Exception {
+ LoginRequest loginRequest = new LoginRequest(getUsername(), getPassword().toCharArray());
+ ObjectMapper mapper = new ObjectMapper();
+ given()
+ .contentType(TEXT)
+ .body(mapper.writeValueAsString(loginRequest))
+ .when()
+ .post(loginUrl)
+ .then()
+ .statusCode(is(SC_UNSUPPORTED_MEDIA_TYPE));
+ }
+
}
private String getPath(URI loginUrl) {
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";
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