From 3faad8a2aa59504d4c04a6f0b5a163f084359e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Wed, 5 Aug 2026 09:34:13 -0400 Subject: [PATCH] KNOX-3407: Support for WebSocket mTLS --- .../websockets/GatewayWebsocketHandler.java | 43 +++++++- .../websockets/ProxyWebSocketAdapter.java | 39 ++++++-- .../websockets/WebsocketLogMessages.java | 5 + .../GatewayWebsocketHandlerTest.java | 99 +++++++++++++++++++ .../websockets/ProxyWebSocketAdapterTest.java | 78 +++++++++++++++ 5 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapterTest.java diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java index f275ee9eeb..60b41c5cdd 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java @@ -25,6 +25,8 @@ import org.apache.knox.gateway.services.registry.ServiceDefEntry; import org.apache.knox.gateway.services.registry.ServiceDefinitionRegistry; import org.apache.knox.gateway.services.registry.ServiceRegistry; +import org.apache.knox.gateway.services.security.AliasService; +import org.apache.knox.gateway.services.security.AliasServiceException; import org.apache.knox.gateway.services.security.KeystoreService; import org.apache.knox.gateway.services.security.KeystoreServiceException; import org.apache.knox.gateway.webshell.WebshellWebSocketAdapter; @@ -68,6 +70,12 @@ public class GatewayWebsocketHandler extends WebSocketHandler static final String REGEX_SPLIT_SERVICE_PATH = "^((?:[^/]*/){3}[^/]*)"; + static final String TRUSTSTORE_USER_PROPERTY = "org.apache.knox.gateway.websockets.truststore"; + + static final String KEYSTORE_USER_PROPERTY = "org.apache.knox.gateway.websockets.keystore"; + + static final String KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY = "org.apache.knox.gateway.websockets.keystore.key.passphrase"; + static final String REGEX_WEBSHELL_REQUEST_PATH = "^(" + SECURE_WEBSOCKET_PROTOCOL_STRING+"|"+WEBSOCKET_PROTOCOL_STRING + ")[^/]+/[^/]+/webshell$"; @@ -150,7 +158,8 @@ public Object createWebSocket(ServletUpgradeRequest req, // Upgrade happens here final ClientEndpointConfig clientConfig = getClientEndpointConfig(req); - clientConfig.getUserProperties().put("org.apache.knox.gateway.websockets.truststore", getTruststore()); + clientConfig.getUserProperties().put(TRUSTSTORE_USER_PROPERTY, getTruststore()); + configureClientIdentity(clientConfig.getUserProperties()); return new ProxyWebSocketAdapter(URI.create(backendURL), pool, clientConfig, config); } catch (final Exception e) { LOG.failedCreatingWebSocket(e); @@ -169,6 +178,38 @@ private KeyStore getTruststore() throws KeystoreServiceException { return trustKeystore; } + /** + * Mirrors DefaultHttpClientFactory#createSSLContext: when two-way SSL is + * enabled, select the client identity keystore (single-EKU aware) and add it, + * with its key passphrase, to the WebSocket client's user properties so the + * outbound TLS handshake can present a client certificate. + */ + void configureClientIdentity(final Map userProperties) + throws KeystoreServiceException, AliasServiceException { + if (!config.isHttpClientTwoWaySslEnabled()) { + return; + } + + final KeystoreService ks = this.services.getService(ServiceType.KEYSTORE_SERVICE); + final AliasService as = this.services.getService(ServiceType.ALIAS_SERVICE); + + final KeyStore identityKeystore; + final char[] identityKeyPassphrase; + if (config.isSingleEkuEnabled()) { + identityKeystore = ks.getKeystoreForHttpClient(); + identityKeyPassphrase = as.getHttpClientKeyPassphrase(); + } else { + identityKeystore = ks.getKeystoreForGateway(); + identityKeyPassphrase = as.getGatewayIdentityPassphrase(); + } + + if (identityKeystore != null) { + userProperties.put(KEYSTORE_USER_PROPERTY, identityKeystore); + userProperties.put(KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY, identityKeyPassphrase); + } else { + LOG.noClientIdentityForTwoWaySsl(); + } + } /** * Returns a {@link ClientEndpointConfig} config that contains the headers diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapter.java b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapter.java index 37c94a6ac5..2774f69a35 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapter.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapter.java @@ -21,6 +21,7 @@ import java.net.URI; import java.util.List; import java.util.ArrayList; +import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -35,6 +36,7 @@ import org.apache.knox.gateway.config.GatewayConfig; import org.eclipse.jetty.io.RuntimeIOException; import org.eclipse.jetty.util.component.LifeCycle; +import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.websocket.api.BatchMode; import org.eclipse.jetty.websocket.api.RemoteEndpoint; import org.eclipse.jetty.websocket.api.Session; @@ -100,21 +102,17 @@ public void onWebSocketConnect(final Session frontEndSession) { container.setAsyncSendTimeout(frontEndSession.getPolicy().getAsyncWriteTimeout()); container.setDefaultMaxSessionIdleTimeout(frontEndSession.getPolicy().getIdleTimeout()); - KeyStore ks = null; - if(clientConfig != null) { - ks = (KeyStore) clientConfig.getUserProperties().get("org.apache.knox.gateway.websockets.truststore"); - } - /* Currently javax.websocket API has no provisions to configure SSL https://github.com/eclipse-ee4j/websocket-api/issues/210 Until that gets fixed we'll have to resort to this. */ - if(container instanceof org.eclipse.jetty.websocket.jsr356.ClientContainer && + if(clientConfig != null && + container instanceof org.eclipse.jetty.websocket.jsr356.ClientContainer && ((org.eclipse.jetty.websocket.jsr356.ClientContainer)container).getClient() != null && ((org.eclipse.jetty.websocket.jsr356.ClientContainer)container).getClient().getSslContextFactory() != null ) { - ((org.eclipse.jetty.websocket.jsr356.ClientContainer)container).getClient().getHttpClient().getSslContextFactory().setTrustStore(ks); - LOG.logMessage("Truststore for websocket setup"); + configureSsl(((org.eclipse.jetty.websocket.jsr356.ClientContainer)container).getClient().getHttpClient().getSslContextFactory(), clientConfig); + LOG.logMessage("SSL for websocket setup"); } final ProxyInboundClient backendSocket = new ProxyInboundClient(getMessageCallback()); @@ -160,6 +158,31 @@ public void onWebSocketConnect(final Session frontEndSession) { } } + /** + * Configures the WebSocket client's SslContextFactory from the values the + * handler placed in the ClientEndpointConfig user properties: the truststore + * (unchanged behavior, may be null) and, when two-way SSL supplied one, the + * client identity keystore plus its key-manager password. + */ + static void configureSsl(final SslContextFactory sslContextFactory, + final ClientEndpointConfig clientConfig) { + final Map userProperties = clientConfig.getUserProperties(); + + sslContextFactory.setTrustStore( + (KeyStore) userProperties.get(GatewayWebsocketHandler.TRUSTSTORE_USER_PROPERTY)); + + final KeyStore identityKeystore = + (KeyStore) userProperties.get(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY); + if (identityKeystore != null) { + sslContextFactory.setKeyStore(identityKeystore); + final char[] passphrase = + (char[]) userProperties.get(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY); + if (passphrase != null) { + sslContextFactory.setKeyManagerPassword(new String(passphrase)); + } + } + } + @Override public void onWebSocketBinary(final byte[] payload, final int offset, final int length) { if (isNotConnected()) { diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/WebsocketLogMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/WebsocketLogMessages.java index fc8484ec3f..9e057e56d9 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/WebsocketLogMessages.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/WebsocketLogMessages.java @@ -60,4 +60,9 @@ void failedCreatingWebSocket( text = "{0}") void debugLog(String message); + @Message(level = MessageLevel.WARN, + text = "Two-way SSL is enabled but no client identity keystore was found; " + + "the outbound WebSocket connection will not present a client certificate") + void noClientIdentityForTwoWaySsl(); + } diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java index 47331b8071..dab6288d9e 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java @@ -27,6 +27,9 @@ import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.provider.federation.jwt.JWTMessages; import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.knox.gateway.services.security.AliasService; +import org.apache.knox.gateway.services.security.KeystoreService; import org.apache.knox.gateway.webshell.WebshellWebSocketAdapter; import org.easymock.EasyMock; import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; @@ -44,8 +47,10 @@ import org.powermock.core.classloader.annotations.PrepareForTest; import org.powermock.modules.junit4.PowerMockRunner; +import java.security.KeyStore; import java.util.Collections; import java.util.Enumeration; +import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.concurrent.ExecutorService; @@ -168,6 +173,100 @@ public void testDisabledWebShell() throws Exception{ gatewayWebsocketHandler.createWebSocket(req,resp); } + @Test + public void testConfigureClientIdentityTwoWaySslGatewayIdentity() throws Exception { + GatewayConfig gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(true).anyTimes(); + EasyMock.expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + + KeyStore identity = KeyStore.getInstance(KeyStore.getDefaultType()); + identity.load(null, null); + char[] passphrase = "gateway-secret".toCharArray(); + + KeystoreService keystoreService = EasyMock.createNiceMock(KeystoreService.class); + EasyMock.expect(keystoreService.getKeystoreForGateway()).andReturn(identity).anyTimes(); + AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + EasyMock.expect(aliasService.getGatewayIdentityPassphrase()).andReturn(passphrase).anyTimes(); + + GatewayServices services = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(services.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).anyTimes(); + EasyMock.expect(services.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).anyTimes(); + EasyMock.replay(gatewayConfig, keystoreService, aliasService, services); + + GatewayWebsocketHandler handler = new GatewayWebsocketHandler(gatewayConfig, services); + Map props = new HashMap<>(); + handler.configureClientIdentity(props); + + Assert.assertSame(identity, props.get(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY)); + Assert.assertSame(passphrase, props.get(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY)); + } + + @Test + public void testConfigureClientIdentityTwoWaySslSingleEku() throws Exception { + GatewayConfig gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(true).anyTimes(); + EasyMock.expect(gatewayConfig.isSingleEkuEnabled()).andReturn(true).anyTimes(); + + KeyStore clientIdentity = KeyStore.getInstance(KeyStore.getDefaultType()); + clientIdentity.load(null, null); + char[] passphrase = "client-secret".toCharArray(); + + KeystoreService keystoreService = EasyMock.createNiceMock(KeystoreService.class); + EasyMock.expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientIdentity).anyTimes(); + AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + EasyMock.expect(aliasService.getHttpClientKeyPassphrase()).andReturn(passphrase).anyTimes(); + + GatewayServices services = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(services.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).anyTimes(); + EasyMock.expect(services.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).anyTimes(); + EasyMock.replay(gatewayConfig, keystoreService, aliasService, services); + + GatewayWebsocketHandler handler = new GatewayWebsocketHandler(gatewayConfig, services); + Map props = new HashMap<>(); + handler.configureClientIdentity(props); + + Assert.assertSame(clientIdentity, props.get(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY)); + Assert.assertSame(passphrase, props.get(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY)); + } + + @Test + public void testConfigureClientIdentityDisabledWhenNotTwoWaySsl() throws Exception { + GatewayConfig gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).anyTimes(); + GatewayServices services = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.replay(gatewayConfig, services); + + GatewayWebsocketHandler handler = new GatewayWebsocketHandler(gatewayConfig, services); + Map props = new HashMap<>(); + handler.configureClientIdentity(props); + + Assert.assertFalse(props.containsKey(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY)); + Assert.assertFalse(props.containsKey(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY)); + } + + @Test + public void testConfigureClientIdentityTwoWaySslNullKeystoreContributesNothing() throws Exception { + GatewayConfig gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(true).anyTimes(); + EasyMock.expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + + KeystoreService keystoreService = EasyMock.createNiceMock(KeystoreService.class); + EasyMock.expect(keystoreService.getKeystoreForGateway()).andReturn(null).anyTimes(); + AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + + GatewayServices services = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(services.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).anyTimes(); + EasyMock.expect(services.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).anyTimes(); + EasyMock.replay(gatewayConfig, keystoreService, aliasService, services); + + GatewayWebsocketHandler handler = new GatewayWebsocketHandler(gatewayConfig, services); + Map props = new HashMap<>(); + handler.configureClientIdentity(props); + + Assert.assertFalse(props.containsKey(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY)); + Assert.assertFalse(props.containsKey(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY)); + } + private ServletUpgradeRequest createServletUpgradeRequest(String url) throws Exception { HttpServletRequest mockRequest = new org.apache.knox.test.mock.MockHttpServletRequest() { @Override diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapterTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapterTest.java new file mode 100644 index 0000000000..7fb659fd3c --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapterTest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.knox.gateway.websockets; + +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.junit.Assert; +import org.junit.Test; + +import javax.websocket.ClientEndpointConfig; +import java.security.KeyStore; + +public class ProxyWebSocketAdapterTest { + + private static KeyStore emptyKeyStore() throws Exception { + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + return keyStore; + } + + @Test + public void testConfigureSslAppliesKeystoreAndTruststore() throws Exception { + KeyStore identity = emptyKeyStore(); + KeyStore truststore = emptyKeyStore(); + ClientEndpointConfig clientConfig = ClientEndpointConfig.Builder.create().build(); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.TRUSTSTORE_USER_PROPERTY, truststore); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY, identity); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY, "secret".toCharArray()); + + SslContextFactory sslContextFactory = new SslContextFactory.Client(); + ProxyWebSocketAdapter.configureSsl(sslContextFactory, clientConfig); + + Assert.assertSame(identity, sslContextFactory.getKeyStore()); + Assert.assertSame(truststore, sslContextFactory.getTrustStore()); + } + + @Test + public void testConfigureSslNoKeystoreWhenAbsent() throws Exception { + KeyStore truststore = emptyKeyStore(); + ClientEndpointConfig clientConfig = ClientEndpointConfig.Builder.create().build(); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.TRUSTSTORE_USER_PROPERTY, truststore); + + SslContextFactory sslContextFactory = new SslContextFactory.Client(); + ProxyWebSocketAdapter.configureSsl(sslContextFactory, clientConfig); + + Assert.assertNull(sslContextFactory.getKeyStore()); + Assert.assertSame(truststore, sslContextFactory.getTrustStore()); + } + + @Test + public void testConfigureSslKeystorePresentNullPassphrase() throws Exception { + KeyStore identity = emptyKeyStore(); + KeyStore truststore = emptyKeyStore(); + ClientEndpointConfig clientConfig = ClientEndpointConfig.Builder.create().build(); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.TRUSTSTORE_USER_PROPERTY, truststore); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY, identity); + + SslContextFactory sslContextFactory = new SslContextFactory.Client(); + ProxyWebSocketAdapter.configureSsl(sslContextFactory, clientConfig); + + Assert.assertSame(identity, sslContextFactory.getKeyStore()); + Assert.assertSame(truststore, sslContextFactory.getTrustStore()); + } +}