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:
+ *
+ * - {@link WhenStrictValidationEnabled} (the default) - a request containing one of the controlled encoded
+ * characters is rejected, whether the target is a routed service or a Gateway-internal endpoint;
+ * - {@link WhenStrictValidationDisabled} - Gateway-internal endpoints remain strictly validated.
+ *
+ * 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:
+ *
+ * - enabled (default) - a strict firewall rejects the controlled encoded characters on any path, both
+ * routed traffic and Gateway-internal endpoints;
+ * - disabled - the routing-aware {@code ApimlStrictServerWebExchangeFirewall} forwards such characters on
+ * routed traffic, while Gateway-internal endpoints remain strictly validated.
+ *
+ * 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.",