diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index a281531946..9794fdaa27 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -147,12 +147,14 @@ jobs: env: APIML_SECURITY_X509_ACCEPTFORWARDEDCERT: true APIML_SECURITY_X509_CERTIFICATESURL: https://cloud-gateway-service:10023/gateway/certificates + APIML_SECURITY_ENABLESTRICTURLVALIDATION: false gateway-service-2: image: ghcr.io/balhar-jakub/gateway-service:${{ github.run_id }}-${{ github.run_number }} env: APIML_SERVICE_HOSTNAME: gateway-service-2 APIML_SERVICE_APIMLID: apiml2 SERVER_INTERNAL_PORT: 10027 + APIML_SECURITY_ENABLESTRICTURLVALIDATION: false cloud-gateway-service: image: ghcr.io/balhar-jakub/cloud-gateway-service:${{ github.run_id }}-${{ github.run_number }} env: diff --git a/config/zowe-api-dev/alternative_start.sh b/config/zowe-api-dev/alternative_start.sh index 2589b20260..b535e1c9f7 100755 --- a/config/zowe-api-dev/alternative_start.sh +++ b/config/zowe-api-dev/alternative_start.sh @@ -58,7 +58,6 @@ _BPX_JOBNAME=${ZOWE_PREFIX}${GATEWAY_CODE} java \ -Dapiml.service.port=${GATEWAY_PORT} \ -Dapiml.service.discoveryServiceUrls=${ZWE_DISCOVERY_SERVICES_LIST} \ -Dapiml.service.preferIpAddress=${APIML_PREFER_IP_ADDRESS} \ - -Dapiml.service.allowEncodedSlashes=${APIML_ALLOW_ENCODED_SLASHES} \ -Dapiml.service.corsEnabled=${APIML_CORS_ENABLED} \ -Dapiml.catalog.serviceId=${APIML_GATEWAY_CATALOG_ID} \ -Dapiml.cache.storage.location=${WORKSPACE_DIR}/api-mediation/ \ diff --git a/gateway-package/src/main/resources/bin/start.sh b/gateway-package/src/main/resources/bin/start.sh index 315457e0d0..b78ae6bef5 100755 --- a/gateway-package/src/main/resources/bin/start.sh +++ b/gateway-package/src/main/resources/bin/start.sh @@ -54,6 +54,7 @@ # - ZWE_configs_apiml_security_authorization_provider # - ZWE_configs_apiml_security_authorization_resourceClass # - ZWE_configs_apiml_security_authorization_resourceNamePrefix +# - ZWE_configs_apiml_security_enableStrictUrlValidation # - ZWE_configs_apiml_security_jwtInitializerTimeout # - ZWE_configs_apiml_security_useInternalMapper # - ZWE_configs_apiml_security_x509_enabled @@ -72,7 +73,6 @@ # - ZWE_configs_apiml_security_oidc_jwks_refreshInternalHours # - ZWE_configs_apiml_security_oidc_userInfo_uri # - ZWE_configs_apiml_security_oidc_validationType -# - ZWE_configs_apiml_service_allowEncodedSlashes - Allows encoded slashes on on URLs through gateway # - ZWE_configs_apiml_service_corsEnabled # - ZWE_configs_certificate_keystore_type / ZWE_zowe_certificate_keystore_type - The keystore type to use for SSL certificates # - ZWE_configs_certificate_key_password / ZWE_zowe_certificate_key_password @@ -376,6 +376,7 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${GATEWAY_CODE} java \ -Dapiml.security.authorization.provider=${ZWE_configs_apiml_security_authorization_provider:-} \ -Dapiml.security.authorization.resourceClass=${ZWE_configs_apiml_security_authorization_resourceClass:-ZOWE} \ -Dapiml.security.authorization.resourceNamePrefix=${ZWE_configs_apiml_security_authorization_resourceNamePrefix:-APIML.} \ + -Dapiml.security.enableStrictUrlValidation=${ZWE_configs_apiml_security_enableStrictUrlValidation:-true} \ -Dapiml.security.forwardHeader.trustedProxies=${ZWE_configs_apiml_security_forwardHeader_trustedProxies:-${ZWE_components_cloudGateway_apiml_security_forwardHeader_trustedProxies:-}} \ -Dapiml.security.jwtInitializerTimeout=${ZWE_configs_apiml_security_jwtInitializerTimeout:-5} \ -Dapiml.security.oidc.clientId=${ZWE_configs_apiml_security_oidc_clientId:-} \ @@ -401,7 +402,6 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${GATEWAY_CODE} java \ -Dapiml.security.x509.externalMapperUrl=${ZWE_configs_apiml_security_x509_externalMapperUrl:-"${internalProtocol:-https}://${ZWE_haInstance_hostname:-localhost}:${ZWE_configs_port:-7554}/zss/api/v1/certificate/x509/map"} \ -Dapiml.security.x509.externalMapperUser=${ZWE_configs_apiml_security_x509_externalMapperUser:-${ZWE_zowe_setup_security_users_zowe:-ZWESVUSR}} \ -Dapiml.security.zosmf.applid=${ZWE_configs_apiml_security_zosmf_applid:-IZUDFLT} \ - -Dapiml.service.allowEncodedSlashes=${ZWE_configs_apiml_service_allowEncodedSlashes:-true} \ -Dapiml.service.apimlId=${ZWE_configs_apimlId:-} \ -Dapiml.service.corsAllowedMethods=${ZWE_configs_apiml_service_corsAllowedMethods:-GET,HEAD,POST,PATCH,DELETE,PUT,OPTIONS} \ -Dapiml.service.corsEnabled=${ZWE_configs_apiml_service_corsEnabled:-false} \ diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/pre/TomcatFilter.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/pre/TomcatFilter.java deleted file mode 100644 index fc402b2270..0000000000 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/pre/TomcatFilter.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * This program and the accompanying materials are made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, and is available at - * https://www.eclipse.org/legal/epl-v20.html - * - * SPDX-License-Identifier: EPL-2.0 - * - * Copyright Contributors to the Zowe Project. - */ - -package org.zowe.apiml.gateway.filters.pre; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.zowe.apiml.message.core.Message; -import org.zowe.apiml.message.core.MessageService; -import org.zowe.apiml.message.log.ApimlLogger; -import org.zowe.apiml.product.logging.annotations.InjectApimlLogger; - -import javax.servlet.*; -import javax.servlet.annotation.WebFilter; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.net.URLDecoder; - -@Component -@WebFilter -@RequiredArgsConstructor -public class TomcatFilter implements Filter { - - private final MessageService messageService; - private final ObjectMapper mapper; - - @Value("${apiml.service.allowEncodedSlashes:#{true}}") - private boolean allowEncodedSlashes; - - @InjectApimlLogger - private final ApimlLogger apimlLog = ApimlLogger.empty(); - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - HttpServletRequest req = (HttpServletRequest) request; - HttpServletResponse res = (HttpServletResponse) response; - - String uri = req.getRequestURI(); - String decodedUri = URLDecoder.decode(uri, "UTF-8"); - final boolean isRequestEncoded = !uri.equals(decodedUri); - - Message message = messageService.createMessage("org.zowe.apiml.gateway.requestContainEncodedSlash", uri); - if (!allowEncodedSlashes && isRequestEncoded) { - res.setStatus(HttpStatus.BAD_REQUEST.value()); - res.setContentType(MediaType.APPLICATION_JSON_VALUE); - try { - mapper.writeValue(res.getWriter(), message.mapToView()); - } catch (IOException e) { - apimlLog.log("org.zowe.apiml.security.errorWrittingResponse", e.getMessage()); - throw new ServletException("Error writing response", e); - } - } else { - chain.doFilter(request, response); - } - } -} diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/ApimlStrictServerWebExchangeFirewall.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/ApimlStrictServerWebExchangeFirewall.java index bddf19cd77..6a0386a37c 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/ApimlStrictServerWebExchangeFirewall.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/ApimlStrictServerWebExchangeFirewall.java @@ -26,7 +26,7 @@ public class ApimlStrictServerWebExchangeFirewall extends StrictHttpFirewall { "/api-doc" }; - private StrictHttpFirewall nonRoutingFirewall = new StrictHttpFirewall(); + private StrictHttpFirewall nonRoutingFirewall = new StrictHttpFirewall(); boolean isPathToRoute(String path, String[] prefixes) { // homepage 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 c3e684cdcb..d06a8c5ed5 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 @@ -120,6 +120,9 @@ public class NewSecurityConfiguration { @Value("${apiml.health.protected:false}") private boolean isHealthEndpointProtected; + @Value("${apiml.security.enableStrictUrlValidation:true}") + private boolean isStrictUrlValidationEnabled; + /** * Login and Logout endpoints *

@@ -598,12 +601,7 @@ class DefaultSecurity { // Web security only needs to be configured once, putting it to multiple filter chains causes multiple evaluations of the same rules @Bean public WebSecurityCustomizer webSecurityCustomizer() { - StrictHttpFirewall firewall = new ApimlStrictServerWebExchangeFirewall(); - firewall.setAllowUrlEncodedSlash(true); - firewall.setAllowBackSlash(true); - firewall.setAllowUrlEncodedPercent(true); - firewall.setAllowUrlEncodedPeriod(true); - firewall.setAllowSemicolon(true); + final StrictHttpFirewall firewall = buildHttpFirewall(); return web -> { web.httpFirewall(firewall); @@ -623,6 +621,26 @@ public WebSecurityCustomizer webSecurityCustomizer() { } }; } + + /** + * Strict URL validation is enabled by default. Setting + * apiml.security.enableStrictUrlValidation=false reverts to the original, relaxed + * behavior where routed requests allow encoded slashes, backslashes, percent, period + * and semicolon characters. + */ + private StrictHttpFirewall buildHttpFirewall() { + if (isStrictUrlValidationEnabled) { + return new StrictHttpFirewall(); + } + + ApimlStrictServerWebExchangeFirewall firewall = new ApimlStrictServerWebExchangeFirewall(); + firewall.setAllowUrlEncodedSlash(true); + firewall.setAllowBackSlash(true); + firewall.setAllowUrlEncodedPercent(true); + firewall.setAllowUrlEncodedPeriod(true); + firewall.setAllowSemicolon(true); + return firewall; + } } @Bean diff --git a/gateway-service/src/main/resources/gateway-log-messages.yml b/gateway-service/src/main/resources/gateway-log-messages.yml index ab0a89b913..8b29b11dd9 100644 --- a/gateway-service/src/main/resources/gateway-log-messages.yml +++ b/gateway-service/src/main/resources/gateway-log-messages.yml @@ -37,13 +37,6 @@ messages: reason: "The request that was issued to the Gateway contains an encoded character in the URL path. The service that the request was addressing does not allow this pattern." action: "Contact the system administrator and request enablement of encoded characters in the service." - - key: org.zowe.apiml.gateway.requestContainEncodedSlash - number: ZWEAG702 - type: ERROR - text: "Gateway does not allow encoded slashes in request: '%s'." - reason: "The request that was issued to the Gateway contains an encoded slash in the URL path. Gateway configuration does not allow this encoding in the URL." - action: "Contact the system administrator and request enablement of encoded slashes in the Gateway." - - key: org.zowe.apiml.gateway.jwtInitConfigError number: ZWEAG704 type: ERROR diff --git a/gateway-service/src/test/java/org/zowe/apiml/acceptance/StrictUrlValidationTest.java b/gateway-service/src/test/java/org/zowe/apiml/acceptance/StrictUrlValidationTest.java new file mode 100644 index 0000000000..fd64cfeeb2 --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/acceptance/StrictUrlValidationTest.java @@ -0,0 +1,126 @@ +/* + * 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.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.NestedTestConfiguration; +import org.springframework.test.context.NestedTestConfiguration.EnclosingConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.zowe.apiml.acceptance.common.AcceptanceTest; +import org.zowe.apiml.acceptance.common.AcceptanceTestWithTwoServices; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import static io.restassured.RestAssured.given; +import static org.apache.http.HttpStatus.SC_BAD_REQUEST; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +/** + * End-to-end acceptance tests for {@code apiml.security.enableStrictUrlValidation} in the (Zuul) Gateway. These + * assert the deterministic, security-relevant behavior that is observable end to end: + *

+ * Rejection is asserted as HTTP status ≥ 400 rather than exactly 400, because the v2 servlet firewall has no + * {@code RequestRejectedHandler} (a rejected request surfaces as 500, and Tomcat rejects some characters even + * earlier as 400). + *

+ * The complementary behavior - that disabling the option relaxes validation for routed traffic for the whole + * set of characters - is verified deterministically at the layer the option actually controls (the Spring + * firewall) by {@code StrictUrlValidationFirewallTest}. It is intentionally not asserted here: end to end a + * routed request also crosses the Tomcat connector, whose own character handling is independent of this option + * and differs between the test harness and the production connector, so it is not a reliable signal for the + * option's behavior. + */ +@NestedTestConfiguration(EnclosingConfiguration.OVERRIDE) +class StrictUrlValidationTest { + + private static final List INTERNAL_ENDPOINTS = Arrays.asList("/gateway", "/application", "/images", "/api-doc"); + + /** + * Every special character checked by strict validation. + */ + static Stream specialCharacters() { + return Stream.of( + "encoded%2Fslash", + "encoded%2F%2Fslash", + "encoded%5Cbackslash", + "encoded%25percent", + "encoded%2Eperiod", + "path;matrix" + ); + } + + static Stream internalEndpointsWithSpecialCharacters() { + return INTERNAL_ENDPOINTS.stream().flatMap(endpoint -> + specialCharacters().map(character -> arguments(endpoint, character))); + } + + @Nested + @AcceptanceTest + @ActiveProfiles("test") + @TestPropertySource(properties = "apiml.security.enableStrictUrlValidation=true") + class WhenStrictValidationEnabled extends AcceptanceTestWithTwoServices { + + @ParameterizedTest(name = "[{0}]") + @MethodSource("org.zowe.apiml.acceptance.StrictUrlValidationTest#specialCharacters") + void whenRoutedRequestContainsSpecialCharacter_thenRejected(String pathSuffix) { + given() + .urlEncodingEnabled(false) + .when() + .get(basePath + serviceWithDefaultConfiguration.getPath() + "/" + pathSuffix) + .then() + .statusCode(is(greaterThanOrEqualTo(SC_BAD_REQUEST))); + } + + @ParameterizedTest(name = "{0}/[{1}]") + @MethodSource("org.zowe.apiml.acceptance.StrictUrlValidationTest#internalEndpointsWithSpecialCharacters") + void whenInternalEndpointRequestContainsSpecialCharacter_thenRejected(String internalPath, String pathSuffix) { + given() + .urlEncodingEnabled(false) + .when() + .get(basePath + internalPath + "/" + pathSuffix) + .then() + .statusCode(is(greaterThanOrEqualTo(SC_BAD_REQUEST))); + } + + } + + @Nested + @AcceptanceTest + @ActiveProfiles("test") + @TestPropertySource(properties = "apiml.security.enableStrictUrlValidation=false") + class WhenStrictValidationDisabled extends AcceptanceTestWithTwoServices { + + @ParameterizedTest(name = "{0}/[{1}]") + @MethodSource("org.zowe.apiml.acceptance.StrictUrlValidationTest#internalEndpointsWithSpecialCharacters") + void whenInternalEndpointRequestContainsSpecialCharacter_thenStillRejected(String internalPath, String pathSuffix) { + given() + .urlEncodingEnabled(false) + .when() + .get(basePath + internalPath + "/" + pathSuffix) + .then() + .statusCode(is(greaterThanOrEqualTo(SC_BAD_REQUEST))); + } + + } + +} diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/pre/TomcatFilterTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/pre/TomcatFilterTest.java deleted file mode 100644 index 0b6a3dc558..0000000000 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/pre/TomcatFilterTest.java +++ /dev/null @@ -1,127 +0,0 @@ -/* - * This program and the accompanying materials are made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, and is available at - * https://www.eclipse.org/legal/epl-v20.html - * - * SPDX-License-Identifier: EPL-2.0 - * - * Copyright Contributors to the Zowe Project. - */ - -package org.zowe.apiml.gateway.filters.pre; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.test.util.ReflectionTestUtils; -import org.zowe.apiml.message.core.Message; -import org.zowe.apiml.message.core.MessageService; -import org.zowe.apiml.message.yaml.YamlMessageService; - -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class TomcatFilterTest { - - private static final String ALLOW_ENCODED_SLASHES_FIELD = "allowEncodedSlashes"; - private static final String ENCODED_REQUEST_URI = "/api/v1/encoded%2fslash"; - private static final String NORMAL_REQUEST_URI = "/api/v1/normal"; - private static MessageService messageService; - @Mock - private ObjectMapper objectMapper; - - private MockHttpServletRequest request; - private MockHttpServletResponse response; - private TomcatFilter filter; - private FilterChain filterChain; - - @BeforeAll - static void initMessageService() { - messageService = new YamlMessageService("/gateway-messages.yml"); - } - - @BeforeEach - void setup() { - request = new MockHttpServletRequest(); - response = new MockHttpServletResponse(); - filterChain = mock(FilterChain.class); - } - - @Test - void shouldRejectEncodedSlashRequestsWhenConfiguredToReject() throws IOException, ServletException { - filter = new TomcatFilter(messageService, objectMapper); - ReflectionTestUtils.setField(filter, ALLOW_ENCODED_SLASHES_FIELD, false); - - request.setRequestURI(ENCODED_REQUEST_URI); - filter.doFilter(request, response, filterChain); - - assertEquals(HttpStatus.BAD_REQUEST.value(), response.getStatus()); - assertEquals(MediaType.APPLICATION_JSON_VALUE, response.getContentType()); - - Message message = messageService.createMessage("org.zowe.apiml.gateway.requestContainEncodedSlash", request.getRequestURI()); - verify(objectMapper).writeValue(response.getWriter(), message.mapToView()); - } - - @Test - void shouldAllowNonEncodedSlashRequestsAndMoveToNextFilterWhenConfiguredToReject() throws IOException, ServletException { - filter = new TomcatFilter(messageService, objectMapper); - ReflectionTestUtils.setField(filter, ALLOW_ENCODED_SLASHES_FIELD, false); - - request.setRequestURI(NORMAL_REQUEST_URI); - filter.doFilter(request, response, filterChain); - - assertEquals(HttpStatus.OK.value(), response.getStatus()); - verify(filterChain).doFilter(request, response); - } - - @Test - void shouldAllowAnyRequestAndMoveToNextFilterWhenConfiguredToAllow() throws IOException, ServletException { - filter = new TomcatFilter(messageService, objectMapper); - ReflectionTestUtils.setField(filter, ALLOW_ENCODED_SLASHES_FIELD, true); - - request.setRequestURI(NORMAL_REQUEST_URI); - filter.doFilter(request, response, filterChain); - - assertEquals(HttpStatus.OK.value(), response.getStatus()); - verify(filterChain).doFilter(request, response); - - request = new MockHttpServletRequest(); - response = new MockHttpServletResponse(); - - request.setRequestURI(ENCODED_REQUEST_URI); - filter.doFilter(request, response, filterChain); - - assertEquals(HttpStatus.OK.value(), response.getStatus()); - verify(filterChain).doFilter(request, response); - } - - @Test - void shouldThrowServletExceptionOnIOExceptionWhenWritingResponse() throws IOException { - HttpServletRequest mockedRequest = mock(HttpServletRequest.class); - HttpServletResponse mockedResponse = mock(HttpServletResponse.class); - - filter = new TomcatFilter(messageService, objectMapper); - ReflectionTestUtils.setField(filter, ALLOW_ENCODED_SLASHES_FIELD, false); - - when(mockedRequest.getRequestURI()).thenReturn(ENCODED_REQUEST_URI); - when(mockedResponse.getWriter()).thenThrow(new IOException()); - - assertThrows(ServletException.class, () -> filter.doFilter(mockedRequest, mockedResponse, filterChain)); - } -} diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/security/config/StrictUrlValidationFirewallTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/security/config/StrictUrlValidationFirewallTest.java new file mode 100644 index 0000000000..23a5cdbff2 --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/security/config/StrictUrlValidationFirewallTest.java @@ -0,0 +1,130 @@ +/* + * 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.security.config; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.firewall.RequestRejectedException; +import org.springframework.security.web.firewall.StrictHttpFirewall; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.servlet.http.HttpServletRequest; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit-level coverage of {@code NewSecurityConfiguration#buildHttpFirewall} and the + * {@code apiml.security.enableStrictUrlValidation} option it consumes. It invokes the real production method + * via reflection (the method is private on the inner {@code DefaultSecurity} configuration) so that the + * selection logic itself is exercised: + *

+ * The end-to-end wiring through the running Gateway is covered by + * {@code org.zowe.apiml.acceptance.StrictUrlValidationTest}. + */ +class StrictUrlValidationFirewallTest { + + private static final String ROUTED_PATH = "/serviceid1/test/"; + private static final String INTERNAL_PATH = "/gateway/"; + + /** + * Every special character checked by strict validation. + */ + static Stream specialCharacters() { + return Stream.concat(charactersRelaxedByOption(), Stream.of("encoded%2F%2Fslash")); + } + + /** + * The characters the Gateway relaxes for routed traffic when the option is disabled. Note that, unlike v3, + * v2 does not relax the double-encoded slash ({@code %2F%2F}) - {@code buildHttpFirewall} does not call + * {@code setAllowUrlEncodedDoubleSlash} - so it stays rejected even when validation is disabled. + */ + static Stream charactersRelaxedByOption() { + return Stream.of( + "encoded%2Fslash", + "encoded%5Cbackslash", + "encoded%25percent", + "encoded%2Eperiod", + "path;matrix" + ); + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("specialCharacters") + void givenStrictValidationEnabled_thenRejectedOnRoutedAndInternalPaths(String pathSuffix) throws Exception { + StrictHttpFirewall firewall = buildHttpFirewall(true); + + assertThrows(RequestRejectedException.class, () -> firewall.getFirewalledRequest(get(ROUTED_PATH + pathSuffix))); + assertThrows(RequestRejectedException.class, () -> firewall.getFirewalledRequest(get(INTERNAL_PATH + pathSuffix))); + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("charactersRelaxedByOption") + void givenStrictValidationDisabled_thenAllowedOnRoutedPath(String pathSuffix) throws Exception { + StrictHttpFirewall firewall = buildHttpFirewall(false); + + assertDoesNotThrow(() -> firewall.getFirewalledRequest(get(ROUTED_PATH + pathSuffix))); + } + + @ParameterizedTest(name = "[{0}]") + @MethodSource("specialCharacters") + void givenStrictValidationDisabled_thenStillRejectedOnInternalPath(String pathSuffix) throws Exception { + StrictHttpFirewall firewall = buildHttpFirewall(false); + + assertThrows(RequestRejectedException.class, () -> firewall.getFirewalledRequest(get(INTERNAL_PATH + pathSuffix))); + } + + private HttpServletRequest get(String uri) { + return new MockHttpServletRequest(HttpMethod.GET.name(), uri); + } + + // buildHttpFirewall is private on the deeply nested DefaultSecurity configuration + // (NewSecurityConfiguration$AccessToken$DefaultSecurity), so it is invoked reflectively. + private static final String CONFIG = NewSecurityConfiguration.class.getName(); + + private StrictHttpFirewall buildHttpFirewall(boolean strictUrlValidationEnabled) throws Exception { + NewSecurityConfiguration config = (NewSecurityConfiguration) newInstance(NewSecurityConfiguration.class, null); + ReflectionTestUtils.setField(config, "isStrictUrlValidationEnabled", strictUrlValidationEnabled); + + Object accessToken = newInstance(Class.forName(CONFIG + "$AccessToken"), config); + Object defaultSecurity = newInstance(Class.forName(CONFIG + "$AccessToken$DefaultSecurity"), accessToken); + + Method buildHttpFirewall = defaultSecurity.getClass().getDeclaredMethod("buildHttpFirewall"); + buildHttpFirewall.setAccessible(true); + return (StrictHttpFirewall) buildHttpFirewall.invoke(defaultSecurity); + } + + /** + * Instantiates a class via its single declared constructor, supplying the enclosing instance (for inner + * classes) as the first argument and {@code null} for every other dependency, which {@code buildHttpFirewall} + * does not use. + */ + private Object newInstance(Class clazz, Object enclosingInstance) throws Exception { + Constructor constructor = clazz.getDeclaredConstructors()[0]; + constructor.setAccessible(true); + Object[] args = new Object[constructor.getParameterCount()]; + if (enclosingInstance != null && args.length > 0) { + args[0] = enclosingInstance; + } + return constructor.newInstance(args); + } + +} diff --git a/gateway-service/src/test/resources/application-test.yml b/gateway-service/src/test/resources/application-test.yml index 4c25b57ba6..8884936fe3 100644 --- a/gateway-service/src/test/resources/application-test.yml +++ b/gateway-service/src/test/resources/application-test.yml @@ -16,7 +16,6 @@ apiml: ipAddress: 127.0.0.1 # IP address that is advertised in Eureka. Default is valid only for localhost scheme: https # "https" or "http" preferIpAddress: false - allowEncodedSlashes: true discoveryServiceUrls: https://localhost:10011/eureka/ corsEnabled: true loadBalancer: diff --git a/gateway-service/src/test/resources/application.yml b/gateway-service/src/test/resources/application.yml index bf0412b782..b53389fbd5 100644 --- a/gateway-service/src/test/resources/application.yml +++ b/gateway-service/src/test/resources/application.yml @@ -13,7 +13,6 @@ apiml: ipAddress: 127.0.0.1 # IP address that is advertised in Eureka. Default is valid only for localhost scheme: https # "https" or "http" preferIpAddress: false - allowEncodedSlashes: true discoveryServiceUrls: https://localhost:10011/eureka/ ignoredHeadersWhenCorsEnabled: Access-Control-Request-Method,Access-Control-Request-Headers,Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers,Access-Control-Allow-Credentials,Origin additionalRegistration: # List of additional Apiml Discovery Services metadata to register with diff --git a/gateway-service/src/test/resources/gateway-messages.yml b/gateway-service/src/test/resources/gateway-messages.yml index d5ed7b33e2..1e09423b84 100644 --- a/gateway-service/src/test/resources/gateway-messages.yml +++ b/gateway-service/src/test/resources/gateway-messages.yml @@ -37,13 +37,6 @@ messages: reason: "The request that was issued to the Gateway contains an encoded character in the URL path. The service that the request was addressing does not allow this pattern." action: "Contact the system administrator and request enablement of encoded characters in the service." - - key: org.zowe.apiml.gateway.requestContainEncodedSlash - number: ZWEAG702 - type: ERROR - text: "Gateway does not allow encoded slashes in request: '%s'." - reason: "The request that was issued to the Gateway contains an encoded slash in the URL path. Gateway configuration does not allow this encoding in the URL." - action: "Contact the system administrator and request enablement of encoded slashes in the Gateway." - - key: org.zowe.apiml.gateway.jwtInitConfigError number: ZWEAG704 type: ERROR diff --git a/integration-tests/src/test/resources/environment-configuration.yml b/integration-tests/src/test/resources/environment-configuration.yml index 965e0fb5bc..559b5b78bb 100644 --- a/integration-tests/src/test/resources/environment-configuration.yml +++ b/integration-tests/src/test/resources/environment-configuration.yml @@ -72,7 +72,7 @@ instanceEnv: ZWE_ALLOWED_DOMAINS: localhost,localhost2,www.zowe.org,zowe.github.io,www.ibm.com,*.cx.internal.cloudapp.net ZWE_haInstance_hostname: localhost ZOWE_PREFIX: ZWE - ZWE_configs_apiml_service_allowEncodedSlashes: true + ZWE_configs_apiml_security_enableStrictUrlValidation: false ZWE_configs_apiml_gateway_timeoutMillis: 10000 ZWE_configs_apiml_security_x509_enabled: true ZWE_configs_apiml_security_auth_provider: zosmf diff --git a/schemas/gateway-schema.json b/schemas/gateway-schema.json index eee96d2939..fad25968ed 100644 --- a/schemas/gateway-schema.json +++ b/schemas/gateway-schema.json @@ -46,6 +46,11 @@ "type": "object", "description": "Authentication and authorization options", "properties": { + "enableStrictUrlValidation": { + "type": "boolean", + "description": "When enabled, the Gateway strictly validates request URLs and rejects encoded characters such as encoded slashes, backslashes, and semicolons. When disabled, validation is relaxed for routed traffic, while Gateway-internal endpoints remain strictly validated.", + "default": true + }, "useInternalMapper": { "type": "boolean", "description": "Enables direct native calls to z/OS to query distributed identity mappings and client certificate mappings. Use only if APIML is running on z/OS.", @@ -192,11 +197,6 @@ "service": { "type": "object", "properties": { - "allowEncodedSlashes": { - "type": "boolean", - "description": "Allow URLs on gateway to contain encoded slashes.", - "default": true - }, "additionalRegistration": { "type": "array", "description": "List of additional Discovery Services URLs to register with and the routing patterns.",