diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/AbstractAuthSchemeFactory.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/AbstractAuthSchemeFactory.java index 37f59b45ed..d2ef12ebcf 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/AbstractAuthSchemeFactory.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/AbstractAuthSchemeFactory.java @@ -18,6 +18,7 @@ import org.apache.commons.lang3.Strings; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; @@ -148,6 +149,9 @@ protected AbstractAuthSchemeFactory(Class configClazz, InstanceInfoService in this.messageService = messageService; } + @Value("${apiml.security.strictSchemeEnforcement:false}") + private boolean strictSchemeEnforcement; + @VisibleForTesting AbstractAuthSchemeFactory() { this(null, null, null); @@ -212,6 +216,11 @@ protected RequestCredentials.RequestCredentialsBuilder createRequestCredentials( * @return mutated request */ protected ServerHttpRequest cleanHeadersOnAuthFail(ServerWebExchange exchange, String errorMessage) { + String serviceId = (String) exchange.getAttribute("apiml.serviceId"); + return cleanHeadersOnAuthFail(exchange, errorMessage, serviceId); + } + + protected ServerHttpRequest cleanHeadersOnAuthFail(ServerWebExchange exchange, String errorMessage, String serviceId) { var otelContext = OtelRequestContext.of(exchange); otelContext.authenticationFailed(); otelContext.authErrorMessage(errorMessage); @@ -221,6 +230,22 @@ protected ServerHttpRequest cleanHeadersOnAuthFail(ServerWebExchange exchange, S // update original request - to remove all potential headers and cookies with credentials Arrays.stream(CERTIFICATE_HEADERS).forEach(headers::remove); + // Strict scheme enforcement: strip Authorization: Basic when appropriate + if (strictSchemeEnforcement) { + AuthenticationScheme scheme = getAuthenticationScheme(); + if (scheme != null && scheme != AuthenticationScheme.BYPASS) { + List authValues = headers.get(HttpHeaders.AUTHORIZATION); + if (authValues != null) { + boolean hasBasic = authValues.stream() + .anyMatch(v -> v != null && v.regionMatches(true, 0, "Basic ", 0, 6)); + if (hasBasic) { + headers.remove(HttpHeaders.AUTHORIZATION); + log.debug("Strict scheme enforcement: stripped Authorization: Basic for service {} (scheme: {})", serviceId, scheme); + } + } + } + } + // set error header in both side (request to the service, response to the user) headers.add(ApimlConstants.AUTH_FAIL_HEADER, errorMessage); exchange.getResponse().getHeaders().add(ApimlConstants.AUTH_FAIL_HEADER, errorMessage); @@ -259,9 +284,12 @@ protected ServerHttpRequest cleanHeadersOnAuthSuccess(ServerWebExchange exchange } protected GatewayFilter createGatewayFilter(T config) { - return (exchange, chain) -> getAuthorizationResponseTransformer(exchange) - .apply(createRequestCredentials(exchange, config).build()) - .flatMap(response -> processResponse(exchange, chain, response)); + return (exchange, chain) -> { + exchange.getAttributes().put("apiml.serviceId", config.getServiceId()); + return getAuthorizationResponseTransformer(exchange) + .apply(createRequestCredentials(exchange, config).build()) + .flatMap(response -> processResponse(exchange, chain, response)); + }; } protected ServerHttpRequest addRequestHeader(ServerWebExchange exchange, String key, String value) { diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/AbstractTokenFilterFactory.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/AbstractTokenFilterFactory.java index 954d6359ac..bdbf504222 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/AbstractTokenFilterFactory.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/AbstractTokenFilterFactory.java @@ -107,7 +107,7 @@ protected Mono processResponse(ServerWebExchange exchange, GatewayFilterCh } } if (request == null) { - request = cleanHeadersOnAuthFail(exchange, failureHeader.orElse("Invalid or missing authentication")); + request = cleanHeadersOnAuthFail(exchange, failureHeader.orElse("Invalid or missing authentication"), (String) exchange.getAttribute("apiml.serviceId")); exchange = exchange.mutate().request(request).build(); } diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/PassticketFilterFactory.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/PassticketFilterFactory.java index 14d6cf3567..cff09a0cc6 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/PassticketFilterFactory.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/PassticketFilterFactory.java @@ -108,7 +108,7 @@ protected Mono processResponse(ServerWebExchange exchange, GatewayFilterCh }).build(); exchange.getResponse().getHeaders().add(ApimlConstants.AUTH_FAIL_HEADER, failureHeader); } else { - request = cleanHeadersOnAuthFail(exchange, failureHeader); + request = cleanHeadersOnAuthFail(exchange, failureHeader, (String) exchange.getAttribute("apiml.serviceId")); } } diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/RoutingConfigurationErrorFilterFactory.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/RoutingConfigurationErrorFilterFactory.java index 6a2fad028f..78151e32dc 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/RoutingConfigurationErrorFilterFactory.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/RoutingConfigurationErrorFilterFactory.java @@ -53,7 +53,7 @@ public GatewayFilter apply(Config config) { return ((exchange, chain) -> { OtelRequestContext.of(exchange).authMethod(authenticationScheme); - super.cleanHeadersOnAuthFail(exchange, config.getMessage()); + super.cleanHeadersOnAuthFail(exchange, config.getMessage(), null); return chain.filter(exchange); }); diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/X509FilterFactory.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/X509FilterFactory.java index 42279bac2c..b392bfe5fa 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/X509FilterFactory.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/filters/X509FilterFactory.java @@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; @@ -28,6 +29,7 @@ import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.Base64; +import java.util.List; import static org.zowe.apiml.constants.ApimlConstants.HTTP_CLIENT_USE_CLIENT_CERTIFICATE; @@ -41,6 +43,9 @@ public class X509FilterFactory extends AbstractGatewayFilterFactory { + // Strict scheme enforcement: strip Authorization: Basic + if (strictSchemeEnforcement) { + List authValues = headers.get(HttpHeaders.AUTHORIZATION); + if (authValues != null) { + boolean hasBasic = authValues.stream() + .anyMatch(v -> v != null && v.regionMatches(true, 0, "Basic ", 0, 6)); + if (hasBasic) { + headers.remove(HttpHeaders.AUTHORIZATION); + log.debug("Strict scheme enforcement: stripped Authorization: Basic for service (scheme: x509)"); + } + } + } + headers.add(ApimlConstants.AUTH_FAIL_HEADER, headerValue); + exchange.getResponse().getHeaders().add(ApimlConstants.AUTH_FAIL_HEADER, headerValue); + }).build(); return request; } diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index adcea536e4..2e2c6a97ad 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -114,6 +114,7 @@ apiml: externalUrl: ${apiml.service.scheme}://${apiml.service.hostname}:${apiml.service.port} security: headersToBeCleared: X-Certificate-Public,X-Certificate-DistinguishedName,X-Certificate-CommonName + strictSchemeEnforcement: false ssl: nonStrictVerifySslCertificatesOfServices: false rauditx: diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/AbstractAuthSchemeFactoryTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/AbstractAuthSchemeFactoryTest.java new file mode 100644 index 0000000000..69e482257e --- /dev/null +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/AbstractAuthSchemeFactoryTest.java @@ -0,0 +1,181 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.filters; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ServerWebExchange; +import org.zowe.apiml.auth.AuthenticationScheme; +import org.zowe.apiml.constants.ApimlConstants; +import org.zowe.apiml.product.opentelemetry.OtelRequestContext; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link AbstractAuthSchemeFactory#cleanHeadersOnAuthFail(ServerWebExchange, String)} + * focusing on the strict scheme enforcement feature. + */ +class AbstractAuthSchemeFactoryTest { + + private static final String BASIC_AUTH_VALUE = "Basic dXNlcjpwYXNz"; + private static final String BEARER_AUTH_VALUE = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.xxx"; + private static final String ERROR_MESSAGE = "auth failed"; + + private OtelRequestContext otelContext; + + @BeforeEach + void setUpOtelContext() { + // OtelRequestContext has a static holder; just ensure it's initialized per test + otelContext = null; + } + + /** + * Create an exchange with an Authorization header and setup OTEL context. + */ + private ServerWebExchange createExchange(String authorizationValue) { + MockServerHttpRequest request = MockServerHttpRequest.get("/test") + .header(HttpHeaders.AUTHORIZATION, authorizationValue) + .build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + otelContext = spy(OtelRequestContext.of(exchange)); + exchange.getAttributes().put("apiml.serviceId", "test-service"); + return exchange; + } + + /** + * Create a spy of AbstractAuthSchemeFactory with the given scheme and enforcement setting. + */ + private AbstractAuthSchemeFactory createFactory(AuthenticationScheme scheme, boolean strictEnforcement) { + AbstractAuthSchemeFactory factory = spy(AbstractAuthSchemeFactory.class); + doReturn(scheme).when(factory).getAuthenticationScheme(); + ReflectionTestUtils.setField(factory, "strictSchemeEnforcement", strictEnforcement); + return factory; + } + + // ── Test 1: strictSchemeEnforcement=true + non-bypass scheme → Basic stripped ── + + @Test + void givenStrictEnforcementAndNonBypassScheme_whenBasicAuthHeader_thenAuthorizationRemoved() { + ServerWebExchange exchange = createExchange(BASIC_AUTH_VALUE); + AbstractAuthSchemeFactory factory = createFactory(AuthenticationScheme.HTTP_BASIC_PASSTICKET, true); + + ServerHttpRequest result = factory.cleanHeadersOnAuthFail(exchange, ERROR_MESSAGE); + + assertNull(result.getHeaders().get(HttpHeaders.AUTHORIZATION), + "Authorization header should be removed under strict enforcement"); + assertNotNull(result.getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER), + "X-Zowe-Auth-Failure header should be set"); + } + + // ── Test 2: strictSchemeEnforcement=false (default) → Basic preserved ── + + @Test + void givenStrictEnforcementDisabled_whenBasicAuthHeader_thenAuthorizationPreserved() { + ServerWebExchange exchange = createExchange(BASIC_AUTH_VALUE); + AbstractAuthSchemeFactory factory = createFactory(AuthenticationScheme.HTTP_BASIC_PASSTICKET, false); + + ServerHttpRequest result = factory.cleanHeadersOnAuthFail(exchange, ERROR_MESSAGE); + + assertNotNull(result.getHeaders().get(HttpHeaders.AUTHORIZATION), + "Authorization header should be preserved when strict enforcement is disabled"); + assertEquals(BASIC_AUTH_VALUE, result.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + // ── Test 3: scheme is BYPASS → Basic always preserved ── + + @Test + void givenStrictEnforcementAndBypassScheme_whenBasicAuthHeader_thenAuthorizationPreserved() { + ServerWebExchange exchange = createExchange(BASIC_AUTH_VALUE); + AbstractAuthSchemeFactory factory = createFactory(AuthenticationScheme.BYPASS, true); + + ServerHttpRequest result = factory.cleanHeadersOnAuthFail(exchange, ERROR_MESSAGE); + + assertNotNull(result.getHeaders().get(HttpHeaders.AUTHORIZATION), + "Authorization header should be preserved for BYPASS scheme even with strict enforcement"); + assertEquals(BASIC_AUTH_VALUE, result.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + // ── Test 4: Authorization: Bearer → never stripped ── + + @Test + void givenStrictEnforcementAndNonBypassScheme_whenBearerAuthHeader_thenAuthorizationPreserved() { + ServerWebExchange exchange = createExchange(BEARER_AUTH_VALUE); + AbstractAuthSchemeFactory factory = createFactory(AuthenticationScheme.ZOWE_JWT, true); + + ServerHttpRequest result = factory.cleanHeadersOnAuthFail(exchange, ERROR_MESSAGE); + + assertNotNull(result.getHeaders().get(HttpHeaders.AUTHORIZATION), + "Bearer Authorization should never be stripped"); + assertEquals(BEARER_AUTH_VALUE, result.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + // ── Test 5: x-zowe-auth-failure still set after stripping ── + + @Test + void givenStrictEnforcement_whenBasicAuthHeaderStripped_thenAuthFailureHeaderSet() { + ServerWebExchange exchange = createExchange(BASIC_AUTH_VALUE); + AbstractAuthSchemeFactory factory = createFactory(AuthenticationScheme.HTTP_BASIC_PASSTICKET, true); + + ServerHttpRequest result = factory.cleanHeadersOnAuthFail(exchange, ERROR_MESSAGE); + + assertEquals(ERROR_MESSAGE, result.getHeaders().getFirst(ApimlConstants.AUTH_FAIL_HEADER), + "X-Zowe-Auth-Failure header should contain the error message"); + } + + // ── Test 6: scheme is null → no enforcement ── + + @Test + void givenStrictEnforcementAndNullScheme_whenBasicAuthHeader_thenAuthorizationPreserved() { + ServerWebExchange exchange = createExchange(BASIC_AUTH_VALUE); + AbstractAuthSchemeFactory factory = createFactory(null, true); + + ServerHttpRequest result = factory.cleanHeadersOnAuthFail(exchange, ERROR_MESSAGE); + + assertNotNull(result.getHeaders().get(HttpHeaders.AUTHORIZATION), + "Authorization header should be preserved when scheme is null"); + assertEquals(BASIC_AUTH_VALUE, result.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + // ── Test 7: case-insensitive match ("basic " lowercase) ── + + @Test + void givenStrictEnforcement_whenLowercaseBasicAuthHeader_thenAuthorizationRemoved() { + ServerWebExchange exchange = createExchange("basic dXNlcjpwYXNz"); + AbstractAuthSchemeFactory factory = createFactory(AuthenticationScheme.HTTP_BASIC_PASSTICKET, true); + + ServerHttpRequest result = factory.cleanHeadersOnAuthFail(exchange, ERROR_MESSAGE); + + assertNull(result.getHeaders().get(HttpHeaders.AUTHORIZATION), + "Authorization header should be removed for case-insensitive 'basic ' match"); + } + + // ── Test 8: serviceId overload passes through ── + + @Test + void givenServiceIdOverload_whenCleanHeadersOnAuthFail_thenBehaviorIdentical() { + ServerWebExchange exchange = createExchange(BASIC_AUTH_VALUE); + AbstractAuthSchemeFactory factory = createFactory(AuthenticationScheme.HTTP_BASIC_PASSTICKET, true); + + ServerHttpRequest result = factory.cleanHeadersOnAuthFail(exchange, ERROR_MESSAGE, "test-service"); + + assertNull(result.getHeaders().get(HttpHeaders.AUTHORIZATION), + "Authorization header should be removed via 3-param overload"); + assertEquals(ERROR_MESSAGE, result.getHeaders().getFirst(ApimlConstants.AUTH_FAIL_HEADER)); + } + +} diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RoutingConfigurationErrorFilterFactoryTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RoutingConfigurationErrorFilterFactoryTest.java index 73bfb42c7a..5994766159 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RoutingConfigurationErrorFilterFactoryTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/RoutingConfigurationErrorFilterFactoryTest.java @@ -64,7 +64,7 @@ void givenConfig_whenApply_thenSetAuthInformationWithoutErrorType() { verify(otelContext).authErrorMessage(MESSAGE); verify(otelContext).authMethod(AuthenticationScheme.SAF_IDT); - verify(underTest).cleanHeadersOnAuthFail(exchange, MESSAGE); + verify(underTest).cleanHeadersOnAuthFail(exchange, MESSAGE, null); } } diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/X509FilterFactoryTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/X509FilterFactoryTest.java index 52d4ebe506..d08a54e633 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/X509FilterFactoryTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/filters/X509FilterFactoryTest.java @@ -25,6 +25,7 @@ import org.springframework.http.server.reactive.SslInfo; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ServerWebExchange; import org.zowe.apiml.auth.AuthenticationScheme; import org.zowe.apiml.constants.ApimlConstants; @@ -46,6 +47,7 @@ import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -253,4 +255,54 @@ void givenX509FilterFactory_whenProcessResponse_thenSetX509Scheme() { } + @Nested + class WhenStrictSchemeEnforcement { + + @Test + void givenStrictEnforcementEnabled_whenNoCertificateInRequest_thenBasicAuthorizationStripped() { + X509FilterFactory testFactory = new X509FilterFactory(messageService); + ReflectionTestUtils.setField(testFactory, "strictSchemeEnforcement", true); + + var request = MockServerHttpRequest.get("/test") + .header(HttpHeaders.AUTHORIZATION, "Basic dXNlcjpwYXNz") + .build(); + var exchange = MockServerWebExchange.from(request); + + var capturedExchange = new java.util.concurrent.atomic.AtomicReference(); + GatewayFilter filter = testFactory.apply(new X509FilterFactory.Config()); + filter.filter(exchange, ex -> { + capturedExchange.set(ex); + return Mono.empty(); + }).block(); + + assertNull(capturedExchange.get().getRequest().getHeaders().get(HttpHeaders.AUTHORIZATION), + "Authorization: Basic should be removed under strict enforcement"); + assertEquals("ZWEAG167E No client certificate provided in the request", + capturedExchange.get().getRequest().getHeaders().getFirst(ApimlConstants.AUTH_FAIL_HEADER)); + } + + @Test + void givenStrictEnforcementDisabled_whenNoCertificateInRequest_thenBasicAuthorizationPreserved() { + X509FilterFactory testFactory = new X509FilterFactory(messageService); + ReflectionTestUtils.setField(testFactory, "strictSchemeEnforcement", false); + + var request = MockServerHttpRequest.get("/test") + .header(HttpHeaders.AUTHORIZATION, "Basic dXNlcjpwYXNz") + .build(); + var exchange = MockServerWebExchange.from(request); + + var capturedExchange = new java.util.concurrent.atomic.AtomicReference(); + GatewayFilter filter = testFactory.apply(new X509FilterFactory.Config()); + filter.filter(exchange, ex -> { + capturedExchange.set(ex); + return Mono.empty(); + }).block(); + + assertNotNull(capturedExchange.get().getRequest().getHeaders().get(HttpHeaders.AUTHORIZATION), + "Authorization: Basic should be preserved when strict enforcement is disabled"); + assertEquals("Basic dXNlcjpwYXNz", + capturedExchange.get().getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + } + }