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: + *

+ */ +@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