diff --git a/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/security/SecurityConfiguration.java b/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/security/SecurityConfiguration.java index 56fda93a00..987688c95f 100644 --- a/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/security/SecurityConfiguration.java +++ b/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/security/SecurityConfiguration.java @@ -35,6 +35,7 @@ import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.zowe.apiml.filter.AttlsFilter; import org.zowe.apiml.filter.SecureConnectionFilter; +import org.zowe.apiml.security.FixedHeadersConfigurer; import org.zowe.apiml.security.client.EnableApimlAuth; import org.zowe.apiml.security.client.login.GatewayLoginProvider; import org.zowe.apiml.security.client.token.GatewayTokenProvider; @@ -197,7 +198,7 @@ SecurityFilterChain basicAuthOrTokenAllEndpointsFilterChain(HttpSecurity http, L } private HttpSecurity baseConfiguration(HttpSecurity http) throws Exception { - http + return FixedHeadersConfigurer.fix(http .csrf(csrf -> csrf.disable()) // NOSONAR .headers(headers -> headers .httpStrictTransportSecurity().disable() @@ -217,9 +218,8 @@ private HttpSecurity baseConfiguration(HttpSecurity http) throws Exception { handlerInitializer.getUnAuthorizedHandler(), new AntPathRequestMatcher("/**") )) .sessionManagement(management -> management - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - - return http; + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + ); } @Bean diff --git a/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/standalone/StandaloneSecurityConfig.java b/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/standalone/StandaloneSecurityConfig.java index 34b39a2373..a2c517f5b4 100644 --- a/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/standalone/StandaloneSecurityConfig.java +++ b/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/standalone/StandaloneSecurityConfig.java @@ -11,8 +11,7 @@ package org.zowe.apiml.apicatalog.standalone; -import javax.annotation.PostConstruct; - +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -20,9 +19,10 @@ import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; +import org.zowe.apiml.security.FixedHeadersConfigurer; import org.zowe.apiml.product.constants.CoreService; -import lombok.extern.slf4j.Slf4j; +import javax.annotation.PostConstruct; @Configuration @ConditionalOnProperty(value = "apiml.catalog.standalone.enabled", havingValue = "true") @@ -37,15 +37,15 @@ void init() { @Bean @Order(Ordered.HIGHEST_PRECEDENCE) public SecurityFilterChain permitAll(HttpSecurity http) throws Exception { - return http - .csrf().disable() // NOSONAR - .headers().httpStrictTransportSecurity().disable() - .frameOptions().disable().and() - - .authorizeRequests() - .anyRequest().permitAll() - .and() - .build(); + return FixedHeadersConfigurer.fix(http + .csrf().disable() // NOSONAR + .headers().httpStrictTransportSecurity().disable() + .frameOptions().disable().and() + + .authorizeRequests() + .anyRequest().permitAll() + .and() + ).build(); } } diff --git a/api-catalog-services/src/test/java/org/zowe/apiml/apicatalog/acceptance/ResponseHeaderFixTest.java b/api-catalog-services/src/test/java/org/zowe/apiml/apicatalog/acceptance/ResponseHeaderFixTest.java new file mode 100644 index 0000000000..bde3fae69c --- /dev/null +++ b/api-catalog-services/src/test/java/org/zowe/apiml/apicatalog/acceptance/ResponseHeaderFixTest.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.apicatalog.acceptance; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Profile; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.zowe.apiml.apicatalog.ApiCatalogApplication; + +import javax.servlet.http.HttpServletResponse; + +import static io.restassured.RestAssured.given; +import static javax.servlet.http.HttpServletResponse.SC_OK; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; + +@SpringBootTest( + classes = { + ApiCatalogApplication.class, + ResponseHeaderFixTest.TestController.class + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@ActiveProfiles("ResponseHeaderFixTest") +@DirtiesContext +class ResponseHeaderFixTest { + + private static final int ADD_HEADER = 0; + private static final int SET_HEADER = 1; + private static final int SET_INT_HEADER = 2; + private static final int ADD_INT_HEADER = 3; + + private static final int TEST_CONTENT_LENGTH = 101; + private static final String CONTENT_LENGTH = "Content-Length"; + + @LocalServerPort + protected int port; + + @ParameterizedTest(name = "Test handling setting context-type using {1}") + @CsvSource({ + ADD_HEADER + ",addHeader", + SET_HEADER + ",setHeader", + SET_INT_HEADER + ",setIntHeader", + ADD_INT_HEADER + ",addIntHeader" + }) + void givenRequest_whenSetContentLength_thenIsPropagated(int method, String description) { + given() + .relaxedHTTPSValidation() + .when() + .get(String.format("https://localhost:%d/apicatalog/test/%d/%s", port, method, CONTENT_LENGTH)) + .then() + .statusCode(SC_OK) + .header(CONTENT_LENGTH, String.valueOf(TEST_CONTENT_LENGTH)) + .header("X-XSS-Protection", is(notNullValue())); + } + + @ParameterizedTest(name = "Test handling headers without content-type using {1}") + @CsvSource({ + ADD_HEADER + ",addHeader", + SET_HEADER + ",setHeader", + SET_INT_HEADER + ",setIntHeader", + ADD_INT_HEADER + ",addIntHeader" + }) + void givenRequest_whenDontSetContentLength_thenIsMissing(int method, String description) { + given() + .relaxedHTTPSValidation() + .when() + .get(String.format("https://localhost:%d/apicatalog/test/%d/%s", port, method, "otherHeaderName")) + .then() + .statusCode(SC_OK) + .header(CONTENT_LENGTH,"0") + .header("X-XSS-Protection", is(notNullValue())); + } + + @RestController + @Profile("ResponseHeaderFixTest") + static class TestController { + + @GetMapping(value = "/test/{method}/{headerName}") + public void getApiDoc(@PathVariable("method") int method, @PathVariable("headerName") String headerName, HttpServletResponse response) { + switch (method) { + case ADD_HEADER: + response.addHeader(headerName, String.valueOf(TEST_CONTENT_LENGTH)); + break; + case SET_HEADER: + response.setHeader(headerName, String.valueOf(TEST_CONTENT_LENGTH)); + break; + case SET_INT_HEADER: + response.setIntHeader(headerName, TEST_CONTENT_LENGTH); + break; + case ADD_INT_HEADER: + response.addIntHeader(headerName, TEST_CONTENT_LENGTH); + break; + default: + throw new IllegalArgumentException("Unknown method: " + method); + } + + } + + } + +} + diff --git a/caching-service/src/main/java/org/zowe/apiml/caching/config/SpringSecurityConfig.java b/caching-service/src/main/java/org/zowe/apiml/caching/config/SpringSecurityConfig.java index 6a6aa6cb47..08aba1c0e4 100644 --- a/caching-service/src/main/java/org/zowe/apiml/caching/config/SpringSecurityConfig.java +++ b/caching-service/src/main/java/org/zowe/apiml/caching/config/SpringSecurityConfig.java @@ -23,6 +23,7 @@ import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; import org.zowe.apiml.filter.AttlsFilter; import org.zowe.apiml.filter.SecureConnectionFilter; +import org.zowe.apiml.security.FixedHeadersConfigurer; import java.util.Collections; @@ -75,7 +76,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeRequests(requests -> requests.anyRequest().permitAll()); } - return http.build(); + return FixedHeadersConfigurer.fix(http).build(); } private UserDetailsService x509UserDetailsService() { diff --git a/caching-service/src/test/java/org/zowe/apiml/caching/acceptance/ResponseHeaderFixTest.java b/caching-service/src/test/java/org/zowe/apiml/caching/acceptance/ResponseHeaderFixTest.java new file mode 100644 index 0000000000..a1f539e6e7 --- /dev/null +++ b/caching-service/src/test/java/org/zowe/apiml/caching/acceptance/ResponseHeaderFixTest.java @@ -0,0 +1,139 @@ +/* + * 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.caching.acceptance; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Profile; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.zowe.apiml.caching.CachingService; +import org.zowe.apiml.util.config.SslContext; +import org.zowe.apiml.util.config.SslContextConfigurer; + +import javax.servlet.http.HttpServletResponse; + +import static io.restassured.RestAssured.given; +import static javax.servlet.http.HttpServletResponse.SC_OK; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; + +@SpringBootTest( + classes = { + CachingService.class, + ResponseHeaderFixTest.TestController.class + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@ActiveProfiles("ResponseHeaderFixTest") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DirtiesContext +class ResponseHeaderFixTest { + + private static final int ADD_HEADER = 0; + private static final int SET_HEADER = 1; + private static final int SET_INT_HEADER = 2; + private static final int ADD_INT_HEADER = 3; + + private static final int TEST_CONTENT_LENGTH = 101; + private static final String CONTENT_LENGTH = "Content-Length"; + + @LocalServerPort + protected int port; + + @Value("${server.ssl.keyPassword}") + private char[] password; + @Value("${server.ssl.keyStore}") + private String clientCertKeystore; + @Value("${server.ssl.keyStore}") + private String keystore; + + @BeforeAll + void setup() throws Exception { + SslContextConfigurer configurer = new SslContextConfigurer(password, clientCertKeystore, keystore); + SslContext.prepareSslAuthentication(configurer); + } + + @ParameterizedTest(name = "Test handling setting context-type using {1}") + @CsvSource({ + ADD_HEADER + ",addHeader", + SET_HEADER + ",setHeader", + SET_INT_HEADER + ",setIntHeader", + ADD_INT_HEADER + ",addIntHeader" + }) + void givenRequest_whenSetContentLength_thenIsPropagated(int method, String description) { + given() + .config(SslContext.clientCertApiml) + .when() + .get(String.format("https://localhost:%d/test/%d/%s", port, method, CONTENT_LENGTH)) + .then() + .statusCode(SC_OK) + .header(CONTENT_LENGTH, String.valueOf(TEST_CONTENT_LENGTH)) + .header("X-Frame-Options", is(notNullValue())) + .header("X-XSS-Protection", is(notNullValue())); + } + + @ParameterizedTest(name = "Test handling headers without content-type using {1}") + @CsvSource({ + ADD_HEADER + ",addHeader", + SET_HEADER + ",setHeader", + SET_INT_HEADER + ",setIntHeader", + ADD_INT_HEADER + ",addIntHeader" + }) + void givenRequest_whenDontSetContentLength_thenIsMissing(int method, String description) { + given() + .config(SslContext.clientCertApiml) + .when() + .get(String.format("https://localhost:%d/test/%d/%s", port, method, "otherHeaderName")) + .then() + .statusCode(SC_OK) + .header(CONTENT_LENGTH,"0") + .header("X-Frame-Options", is(notNullValue())) + .header("X-XSS-Protection", is(notNullValue())); + } + + @RestController + @Profile("ResponseHeaderFixTest") + static class TestController { + + @GetMapping(value = "/test/{method}/{headerName}") + public void getApiDoc(@PathVariable("method") int method, @PathVariable("headerName") String headerName, HttpServletResponse response) { + switch (method) { + case ADD_HEADER: + response.addHeader(headerName, String.valueOf(TEST_CONTENT_LENGTH)); + break; + case SET_HEADER: + response.setHeader(headerName, String.valueOf(TEST_CONTENT_LENGTH)); + break; + case SET_INT_HEADER: + response.setIntHeader(headerName, TEST_CONTENT_LENGTH); + break; + case ADD_INT_HEADER: + response.addIntHeader(headerName, TEST_CONTENT_LENGTH); + break; + default: + throw new IllegalArgumentException("Unknown method: " + method); + } + + } + + } + +} + diff --git a/common-service-core/build.gradle b/common-service-core/build.gradle index d672d315b9..45be0f4fe8 100644 --- a/common-service-core/build.gradle +++ b/common-service-core/build.gradle @@ -26,6 +26,9 @@ dependencies { compileOnly libs.eh.cache testImplementation libs.eh.cache + compileOnly libs.spring.security.config + compileOnly libs.spring.security.web + implementation libs.spring.boot.starter.cache testImplementation libs.spring.boot.starter.cache implementation libs.spring.web diff --git a/common-service-core/src/main/java/org/zowe/apiml/security/FixedHeadersConfigurer.java b/common-service-core/src/main/java/org/zowe/apiml/security/FixedHeadersConfigurer.java new file mode 100644 index 0000000000..83be2d1b86 --- /dev/null +++ b/common-service-core/src/main/java/org/zowe/apiml/security/FixedHeadersConfigurer.java @@ -0,0 +1,240 @@ +/* + * 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; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import org.springframework.security.config.annotation.web.HttpSecurityBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.web.header.HeaderWriter; +import org.springframework.security.web.header.HeaderWriterFilter; +import org.springframework.security.web.util.OnCommittedResponseWrapper; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.List; + +@RequiredArgsConstructor +public class FixedHeadersConfigurer> extends HeadersConfigurer { + + @Delegate(excludes = Configure.class) + protected final HeadersConfigurer original; + + public static > HttpSecurity fix(HttpSecurity httpSecurity) throws Exception { + // remove the invalid configured + HeadersConfigurer originalConfigurer = httpSecurity.removeConfigurer(HeadersConfigurer.class); + + // add back the fixed version + httpSecurity.apply(new FixedHeadersConfigurer<>(originalConfigurer)); + + return httpSecurity; + } + + @Override + public void configure(H http) { + HeaderWriterFilter headersFilter = createHeaderWriterFilterFixed(); + http.addFilter(headersFilter); + } + + private HeaderWriterFilter createHeaderWriterFilterFixed() { + List writers; + try { + // to do not duplicate code, rather call the original private method + Method getHeaderWriters = HeadersConfigurer.class.getDeclaredMethod("getHeaderWriters"); + getHeaderWriters.setAccessible(true); + writers = (List) getHeaderWriters.invoke(original); + } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | + InvocationTargetException e) { + throw new IllegalStateException("The implementation was changed", e); + } + + if (writers.isEmpty()) { + throw new IllegalStateException( + "Headers security is enabled, but no headers will be added. Either add headers or disable headers security"); + } + HeaderWriterFilter headersFilter = new FixedHeaderWriterFilter(writers); + headersFilter = postProcess(headersFilter); + return headersFilter; + } + + interface Configure> { + + void configure(H http); + + } + + abstract static class FixedOnCommittedResponseWrapper extends OnCommittedResponseWrapper { + + FixedOnCommittedResponseWrapper(HttpServletResponse response) { + super(response); + } + + @Override + public void addHeader(String name, String value) { + checkContentLengthHeader(name, value); + super.addHeader(name, value); + } + + @Override + public void addIntHeader(String name, int value) { + checkContentLengthHeader(name, value); + super.addIntHeader(name, value); + } + + @Override + public void setHeader(String name, String value) { + checkContentLengthHeader(name, value); + super.setHeader(name, value); + } + + @Override + public void setIntHeader(String name, int value) { + checkContentLengthHeader(name, value); + super.setIntHeader(name, value); + } + + private void checkContentLengthHeader(String name, int value) { + if ("Content-Length".equalsIgnoreCase(name)) { + setContentLength(value); + } + } + + private void checkContentLengthHeader(String name, String value) { + if ("Content-Length".equalsIgnoreCase(name)) { + setContentLength(Integer.parseInt(value)); + } + } + + } + + static class FixedHeaderWriterFilter extends HeaderWriterFilter { + + private final List headerWriters; + + private boolean shouldWriteHeadersEagerly = false; + + public FixedHeaderWriterFilter(List headerWriters) { + super(headerWriters); + this.headerWriters = headerWriters; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (this.shouldWriteHeadersEagerly) { + doHeadersBeforeCopy(request, response, filterChain); + } + else { + doHeadersAfterFixed(request, response, filterChain); + } + } + + private void doHeadersBeforeCopy(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws IOException, ServletException { + writeHeadersCopy(request, response); + filterChain.doFilter(request, response); + } + + private void doHeadersAfterFixed(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws IOException, ServletException { + FixedHeaderWriterResponse headerWriterResponse = new FixedHeaderWriterResponse(request, response); + FixedHeaderWriterRequest headerWriterRequest = new FixedHeaderWriterRequest(request, headerWriterResponse); + try { + filterChain.doFilter(headerWriterRequest, headerWriterResponse); + } + finally { + headerWriterResponse.writeHeaders(); + } + } + + void writeHeadersCopy(HttpServletRequest request, HttpServletResponse response) { + for (HeaderWriter writer : this.headerWriters) { + writer.writeHeaders(request, response); + } + } + + class FixedHeaderWriterResponse extends FixedOnCommittedResponseWrapper { + + private final HttpServletRequest request; + + FixedHeaderWriterResponse(HttpServletRequest request, HttpServletResponse response) { + super(response); + this.request = request; + } + + @Override + protected void onResponseCommitted() { + writeHeaders(); + this.disableOnResponseCommitted(); + } + + protected void writeHeaders() { + if (isDisableOnResponseCommitted()) { + return; + } + FixedHeaderWriterFilter.this.writeHeadersCopy(this.request, getHttpResponse()); + } + + private HttpServletResponse getHttpResponse() { + return (HttpServletResponse) getResponse(); + } + + } + + static class FixedHeaderWriterRequest extends HttpServletRequestWrapper { + + private final FixedHeaderWriterResponse response; + + FixedHeaderWriterRequest(HttpServletRequest request, FixedHeaderWriterResponse response) { + super(request); + this.response = response; + } + + @Override + public RequestDispatcher getRequestDispatcher(String path) { + return new FixedHeaderWriterFilter.FixedHeaderWriterRequestDispatcher(super.getRequestDispatcher(path), this.response); + } + + } + + static class FixedHeaderWriterRequestDispatcher implements RequestDispatcher { + + private final RequestDispatcher delegate; + + private final FixedHeaderWriterResponse response; + + FixedHeaderWriterRequestDispatcher(RequestDispatcher delegate, FixedHeaderWriterResponse response) { + this.delegate = delegate; + this.response = response; + } + + @Override + public void forward(ServletRequest request, ServletResponse response) throws ServletException, IOException { + this.delegate.forward(request, response); + } + + @Override + public void include(ServletRequest request, ServletResponse response) throws ServletException, IOException { + this.response.onResponseCommitted(); + this.delegate.include(request, response); + } + + } + + } + +} diff --git a/discovery-service/src/main/java/org/zowe/apiml/discovery/config/AbstractWebSecurityConfigurer.java b/discovery-service/src/main/java/org/zowe/apiml/discovery/config/AbstractWebSecurityConfigurer.java index bbc87cc9b0..3da1d8e4d6 100644 --- a/discovery-service/src/main/java/org/zowe/apiml/discovery/config/AbstractWebSecurityConfigurer.java +++ b/discovery-service/src/main/java/org/zowe/apiml/discovery/config/AbstractWebSecurityConfigurer.java @@ -12,13 +12,14 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.zowe.apiml.security.FixedHeadersConfigurer; public abstract class AbstractWebSecurityConfigurer { protected HttpSecurity baseConfigure(HttpSecurity http) throws Exception { - return http.csrf().disable() // NOSONAR + return FixedHeadersConfigurer.fix(http.csrf().disable() // NOSONAR .headers().httpStrictTransportSecurity().disable() .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) - .and(); + .and()); } } diff --git a/discovery-service/src/test/java/org/zowe/apiml/discovery/acceptance/ResponseHeaderFixTest.java b/discovery-service/src/test/java/org/zowe/apiml/discovery/acceptance/ResponseHeaderFixTest.java new file mode 100644 index 0000000000..739862488e --- /dev/null +++ b/discovery-service/src/test/java/org/zowe/apiml/discovery/acceptance/ResponseHeaderFixTest.java @@ -0,0 +1,124 @@ +/* + * 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.discovery.acceptance; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Profile; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.zowe.apiml.discovery.DiscoveryServiceApplication; + +import javax.servlet.http.HttpServletResponse; + +import static io.restassured.RestAssured.given; +import static javax.servlet.http.HttpServletResponse.SC_OK; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; + +@SpringBootTest( + classes = { + DiscoveryServiceApplication.class, + ResponseHeaderFixTest.TestController.class + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@ActiveProfiles("ResponseHeaderFixTest") +@DirtiesContext +class ResponseHeaderFixTest { + + private static final int ADD_HEADER = 0; + private static final int SET_HEADER = 1; + private static final int SET_INT_HEADER = 2; + private static final int ADD_INT_HEADER = 3; + + private static final int TEST_CONTENT_LENGTH = 101; + private static final String CONTENT_LENGTH = "Content-Length"; + + @LocalServerPort + protected int port; + + @ParameterizedTest(name = "Test handling setting context-type using {1}") + @CsvSource({ + ADD_HEADER + ",addHeader", + SET_HEADER + ",setHeader", + SET_INT_HEADER + ",setIntHeader", + ADD_INT_HEADER + ",addIntHeader" + }) + void givenRequest_whenSetContentLength_thenIsPropagated(int method, String description) { + given() + .relaxedHTTPSValidation() + .auth().preemptive().basic("eureka", "password") + .when() + .get(String.format("http://localhost:%d/test/%d/%s", port, method, CONTENT_LENGTH)) + .then() + .statusCode(SC_OK) + .header(CONTENT_LENGTH, String.valueOf(TEST_CONTENT_LENGTH)) + .header("X-Frame-Options", is(notNullValue())) + .header("Cache-Control", is(notNullValue())) + .header("X-XSS-Protection", is(notNullValue())); + } + + @ParameterizedTest(name = "Test handling headers without content-type using {1}") + @CsvSource({ + ADD_HEADER + ",addHeader", + SET_HEADER + ",setHeader", + SET_INT_HEADER + ",setIntHeader", + ADD_INT_HEADER + ",addIntHeader" + }) + void givenRequest_whenDontSetContentLength_thenIsMissing(int method, String description) { + given() + .relaxedHTTPSValidation() + .auth().preemptive().basic("eureka", "password") + .when() + .get(String.format("http://localhost:%d/test/%d/%s", port, method, "otherHeaderName")) + .then() + .statusCode(SC_OK) + .header(CONTENT_LENGTH,"0") + .header("X-Frame-Options", is(notNullValue())) + .header("Cache-Control", is(notNullValue())) + .header("X-XSS-Protection", is(notNullValue())); + } + + @RestController + @Profile("ResponseHeaderFixTest") + static class TestController { + + @GetMapping(value = "/test/{method}/{headerName}") + public void getApiDoc(@PathVariable("method") int method, @PathVariable("headerName") String headerName, HttpServletResponse response) { + switch (method) { + case ADD_HEADER: + response.addHeader(headerName, String.valueOf(TEST_CONTENT_LENGTH)); + break; + case SET_HEADER: + response.setHeader(headerName, String.valueOf(TEST_CONTENT_LENGTH)); + break; + case SET_INT_HEADER: + response.setIntHeader(headerName, TEST_CONTENT_LENGTH); + break; + case ADD_INT_HEADER: + response.addIntHeader(headerName, TEST_CONTENT_LENGTH); + break; + default: + throw new IllegalArgumentException("Unknown method: " + method); + } + + } + + } + +} + diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/NewSecurityConfiguration.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/NewSecurityConfiguration.java index bf4918f077..e6530a9d34 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/NewSecurityConfiguration.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/NewSecurityConfiguration.java @@ -57,6 +57,7 @@ import org.zowe.apiml.gateway.security.ticket.SuccessfulTicketHandler; import org.zowe.apiml.gateway.services.ServicesInfoController; import org.zowe.apiml.gateway.zaas.ZaasAuthenticationFilter; +import org.zowe.apiml.security.FixedHeadersConfigurer; import org.zowe.apiml.security.common.config.AuthConfigurationProperties; import org.zowe.apiml.security.common.config.CertificateAuthenticationProvider; import org.zowe.apiml.security.common.config.HandlerInitializer; @@ -649,7 +650,7 @@ protected HttpSecurity baseConfigure(HttpSecurity http) throws Exception { http.addFilterBefore(new AttlsFilter(), org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter.class); http.addFilterBefore(new SecureConnectionFilter(), AttlsFilter.class); } - return http + return FixedHeadersConfigurer.fix(http .cors(withDefaults()).csrf(csrf -> csrf.disable()) // NOSONAR we are using SAMESITE cookie to mitigate CSRF .headers(headers -> headers .httpStrictTransportSecurity(HeadersConfigurer.HstsConfig::disable) @@ -658,9 +659,7 @@ protected HttpSecurity baseConfigure(HttpSecurity http) throws Exception { .exceptionHandling(handling -> handling .authenticationEntryPoint(handlerInitializer.getBasicAuthUnauthorizedHandler())) .sessionManagement(management -> management - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .exceptionHandling(handling -> handling - .authenticationEntryPoint(handlerInitializer.getBasicAuthUnauthorizedHandler())); + .sessionCreationPolicy(SessionCreationPolicy.STATELESS))); } private UserDetailsService x509UserDetailsService() { diff --git a/gateway-service/src/test/java/org/zowe/apiml/acceptance/ResponseHeaderFixTest.java b/gateway-service/src/test/java/org/zowe/apiml/acceptance/ResponseHeaderFixTest.java new file mode 100644 index 0000000000..1405b0dbbc --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/acceptance/ResponseHeaderFixTest.java @@ -0,0 +1,119 @@ +/* + * 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.acceptance; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Profile; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.zowe.apiml.gateway.GatewayApplication; + +import javax.servlet.http.HttpServletResponse; + +import static io.restassured.RestAssured.given; +import static javax.servlet.http.HttpServletResponse.SC_OK; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; + +@SpringBootTest( + classes = { + GatewayApplication.class, + ResponseHeaderFixTest.TestController.class + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@ActiveProfiles("ResponseHeaderFixTest") +@DirtiesContext +class ResponseHeaderFixTest { + + private static final int ADD_HEADER = 0; + private static final int SET_HEADER = 1; + private static final int SET_INT_HEADER = 2; + private static final int ADD_INT_HEADER = 3; + + private static final int TEST_CONTENT_LENGTH = 101; + private static final String CONTENT_LENGTH = "Content-Length"; + + @LocalServerPort + protected int port; + + @ParameterizedTest(name = "Test handling setting context-type using {1}") + @CsvSource({ + ADD_HEADER + ",addHeader", + SET_HEADER + ",setHeader", + SET_INT_HEADER + ",setIntHeader", + ADD_INT_HEADER + ",addIntHeader" + }) + void givenRequest_whenSetContentLength_thenIsPropagated(int method, String description) { + given() + .relaxedHTTPSValidation() + .when() + .get(String.format("https://localhost:%d/test/%d/%s", port, method, CONTENT_LENGTH)) + .then() + .statusCode(SC_OK) + .header(CONTENT_LENGTH, String.valueOf(TEST_CONTENT_LENGTH)) + .header("Strict-Transport-Security", is(notNullValue())) + .header("X-XSS-Protection", is(notNullValue())); + } + + @ParameterizedTest(name = "Test handling headers without content-type using {1}") + @CsvSource({ + ADD_HEADER + ",addHeader", + SET_HEADER + ",setHeader", + SET_INT_HEADER + ",setIntHeader", + ADD_INT_HEADER + ",addIntHeader" + }) + void givenRequest_whenDontSetContentLength_thenIsMissing(int method, String description) { + given() + .relaxedHTTPSValidation() + .when() + .get(String.format("https://localhost:%d/test/%d/%s", port, method, "otherHeaderName")) + .then() + .statusCode(SC_OK) + .header(CONTENT_LENGTH,"0") + .header("Strict-Transport-Security", is(notNullValue())) + .header("X-XSS-Protection", is(notNullValue())); + } + + @RestController + @Profile("ResponseHeaderFixTest") + static class TestController { + + @GetMapping(value = "/test/{method}/{headerName}") + public void getApiDoc(@PathVariable("method") int method, @PathVariable("headerName") String headerName, HttpServletResponse response) { + switch (method) { + case ADD_HEADER: + response.addHeader(headerName, String.valueOf(TEST_CONTENT_LENGTH)); + break; + case SET_HEADER: + response.setHeader(headerName, String.valueOf(TEST_CONTENT_LENGTH)); + break; + case SET_INT_HEADER: + response.setIntHeader(headerName, TEST_CONTENT_LENGTH); + break; + case ADD_INT_HEADER: + response.addIntHeader(headerName, TEST_CONTENT_LENGTH); + break; + default: + throw new IllegalArgumentException("Unknown method: " + method); + } + + } + + } + +} diff --git a/metrics-service/src/main/java/org/zowe/apiml/metrics/security/SpringSecurityConfiguration.java b/metrics-service/src/main/java/org/zowe/apiml/metrics/security/SpringSecurityConfiguration.java index ca47fcf808..1f081f1efb 100644 --- a/metrics-service/src/main/java/org/zowe/apiml/metrics/security/SpringSecurityConfiguration.java +++ b/metrics-service/src/main/java/org/zowe/apiml/metrics/security/SpringSecurityConfiguration.java @@ -26,6 +26,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.zowe.apiml.security.FixedHeadersConfigurer; import org.zowe.apiml.security.client.EnableApimlAuth; import org.zowe.apiml.security.client.login.GatewayLoginProvider; import org.zowe.apiml.security.client.token.GatewayTokenProvider; @@ -111,7 +112,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .antMatchers("/application/health", "/application/info").permitAll() .and().apply(new CustomSecurityFilters()); - return http.build(); + return FixedHeadersConfigurer.fix(http).build(); } private class CustomSecurityFilters extends AbstractHttpConfigurer { diff --git a/metrics-service/src/test/java/org/zowe/apiml/metrics/acceptance/ResponseHeaderFixTest.java b/metrics-service/src/test/java/org/zowe/apiml/metrics/acceptance/ResponseHeaderFixTest.java new file mode 100644 index 0000000000..39ff6e29f7 --- /dev/null +++ b/metrics-service/src/test/java/org/zowe/apiml/metrics/acceptance/ResponseHeaderFixTest.java @@ -0,0 +1,124 @@ +/* + * 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.metrics.acceptance; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Profile; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.zowe.apiml.metrics.MetricsServiceApplication; + +import javax.servlet.http.HttpServletResponse; + +import static io.restassured.RestAssured.given; +import static javax.servlet.http.HttpServletResponse.SC_OK; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; + +@SpringBootTest( + classes = { + MetricsServiceApplication.class, + ResponseHeaderFixTest.TestController.class + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "server.ssl.keyStore=../keystore/localhost/localhost.keystore.p12", + "server.ssl.trustStore=../keystore/localhost/localhost.truststore.p12" + } +) +@ActiveProfiles("ResponseHeaderFixTest") +@DirtiesContext +class ResponseHeaderFixTest { + + private static final int ADD_HEADER = 0; + private static final int SET_HEADER = 1; + private static final int SET_INT_HEADER = 2; + private static final int ADD_INT_HEADER = 3; + + private static final int TEST_CONTENT_LENGTH = 101; + private static final String CONTENT_LENGTH = "Content-Length"; + + @LocalServerPort + protected int port; + + @ParameterizedTest(name = "Test handling setting context-type using {1}") + @CsvSource({ + ADD_HEADER + ",addHeader", + SET_HEADER + ",setHeader", + SET_INT_HEADER + ",setIntHeader", + ADD_INT_HEADER + ",addIntHeader" + }) + void givenRequest_whenSetContentLength_thenIsPropagated(int method, String description) { + given() + .relaxedHTTPSValidation() + .when() + .get(String.format("https://localhost:%d/metrics-service/test/%d/%s", port, method, CONTENT_LENGTH)) + .then() + .statusCode(SC_OK) + .header(CONTENT_LENGTH, String.valueOf(TEST_CONTENT_LENGTH)) + .header("Cache-Control", is(notNullValue())) + .header("X-XSS-Protection", is(notNullValue())); + } + + @ParameterizedTest(name = "Test handling headers without content-type using {1}") + @CsvSource({ + ADD_HEADER + ",addHeader", + SET_HEADER + ",setHeader", + SET_INT_HEADER + ",setIntHeader", + ADD_INT_HEADER + ",addIntHeader" + }) + void givenRequest_whenDontSetContentLength_thenIsMissing(int method, String description) { + given() + .relaxedHTTPSValidation() + .when() + .get(String.format("https://localhost:%d/metrics-service/test/%d/%s", port, method, "otherHeaderName")) + .then() + .statusCode(SC_OK) + .header(CONTENT_LENGTH,"0") + .header("Cache-Control", is(notNullValue())) + .header("X-XSS-Protection", is(notNullValue())); + } + + @RestController + @Profile("ResponseHeaderFixTest") + static class TestController { + + @GetMapping(value = "/test/{method}/{headerName}") + public void getApiDoc(@PathVariable("method") int method, @PathVariable("headerName") String headerName, HttpServletResponse response) { + switch (method) { + case ADD_HEADER: + response.addHeader(headerName, String.valueOf(TEST_CONTENT_LENGTH)); + break; + case SET_HEADER: + response.setHeader(headerName, String.valueOf(TEST_CONTENT_LENGTH)); + break; + case SET_INT_HEADER: + response.setIntHeader(headerName, TEST_CONTENT_LENGTH); + break; + case ADD_INT_HEADER: + response.addIntHeader(headerName, TEST_CONTENT_LENGTH); + break; + default: + throw new IllegalArgumentException("Unknown method: " + method); + } + + } + + } + +} + diff --git a/metrics-service/src/test/java/org/zowe/apiml/metrics/functional/MetricsFunctionalTest.java b/metrics-service/src/test/java/org/zowe/apiml/metrics/functional/MetricsFunctionalTest.java deleted file mode 100644 index e0a6f1900d..0000000000 --- a/metrics-service/src/test/java/org/zowe/apiml/metrics/functional/MetricsFunctionalTest.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * This program and the accompanying materials are made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, and is available at - * https://www.eclipse.org/legal/epl-v20.html - * - * SPDX-License-Identifier: EPL-2.0 - * - * Copyright Contributors to the Zowe Project. - */ - -package org.zowe.apiml.metrics.functional; - -import org.junit.jupiter.api.BeforeEach; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.web.server.LocalServerPort; -import org.zowe.apiml.metrics.MetricsServiceApplication; - -import io.restassured.RestAssured; - -@SpringBootTest(classes = MetricsServiceApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { - "server.ssl.keyStore=../keystore/localhost/localhost.keystore.p12", - "server.ssl.trustStore=../keystore/localhost/localhost.truststore.p12" }) -public abstract class MetricsFunctionalTest { - @LocalServerPort - protected int port; - - @Value("${apiml.service.hostname:localhost}") - protected String hostname; - - @BeforeEach - void setUp() { - RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); - } - - protected String getDiscoveryUriWithPath(String path) { - return String.format("https://%s:%d", hostname, port) + path; - } - -} diff --git a/metrics-service/src/test/java/org/zowe/apiml/metrics/functional/PrototypeFunctionalTest.java b/metrics-service/src/test/java/org/zowe/apiml/metrics/functional/PrototypeFunctionalTest.java deleted file mode 100644 index 8d2fbddb05..0000000000 --- a/metrics-service/src/test/java/org/zowe/apiml/metrics/functional/PrototypeFunctionalTest.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * This program and the accompanying materials are made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, and is available at - * https://www.eclipse.org/legal/epl-v20.html - * - * SPDX-License-Identifier: EPL-2.0 - * - * Copyright Contributors to the Zowe Project. - */ - -package org.zowe.apiml.metrics.functional; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class PrototypeFunctionalTest extends MetricsFunctionalTest { - @Test - void testContextStarts() { - assertTrue(true); - } -}