Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,15 @@ public void onClosed(Closeable closeable, ICloseType type) throws IOException {
tryReleaseConnectionPermit();

if (!socket.connectPromise.isDone()) {
socket.connectPromise.handleException(new ConnectorIOException(
"Connection is closed before WebSocket is established"));
final Throwable handshakeFailure =
ConnectionManager.getHandshakeFailure(conn);
socket.connectPromise.handleException(handshakeFailure != null
? new ConnectorIOException(
"TLS handshake failed before WebSocket is established: "
+ handshakeFailure.getMessage(),
handshakeFailure)
: new ConnectorIOException(
"Connection is closed before WebSocket is established"));
}
// Immediately try to reconnect
if (System.currentTimeMillis() - lastConnectithenOnException.get() > 30000) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* See the Apache License Version 2.0 for the specific language governing permissions and limitations there under.
* ====================
* Portions Copyrighted 2015 ForgeRock AS.
* Portions Copyrighted 2026 3A Systems, LLC
*/

/**
Expand All @@ -32,6 +33,8 @@
import java.util.logging.Logger;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLParameters;

import org.forgerock.openicf.framework.remote.ReferenceCountedObject;
import org.forgerock.openicf.framework.remote.rpc.OperationMessageListener;
Expand Down Expand Up @@ -64,6 +67,7 @@
import org.glassfish.grizzly.http.util.MimeHeaders;
import org.glassfish.grizzly.nio.transport.TCPNIOTransport;
import org.glassfish.grizzly.nio.transport.TCPNIOTransportBuilder;
import org.glassfish.grizzly.ssl.SSLConnectionContext;
import org.glassfish.grizzly.ssl.SSLContextConfigurator;
import org.glassfish.grizzly.ssl.SSLEngineConfigurator;
import org.glassfish.grizzly.ssl.SSLFilter;
Expand Down Expand Up @@ -308,7 +312,9 @@ public void onTimeout(Connection connection) {
}
final SSLEngineConfigurator configurator =
new SSLEngineConfigurator(context, true, false, false);
final SwitchingSSLFilter filter = new SwitchingSSLFilter(configurator, defaultSecState);
final SwitchingSSLFilter filter =
new SwitchingSSLFilter(configurator, defaultSecState, clientConfig
.isHostnameVerification());
fcb.add(filter);

final AsyncHttpClientEventFilter eventFilter =
Expand Down Expand Up @@ -476,6 +482,15 @@ public String getAuthority() {
return connectionInfo.getRemoteURI().getAuthority();
}

/** The host of the remote URI, without the brackets of an IPv6 literal. */
String getHost() {
String host = connectionInfo.getRemoteURI().getHost();
if (host != null && host.startsWith("[") && host.endsWith("]")) {
host = host.substring(1, host.length() - 1);
}
return host;
}

boolean isGracefullyFinishResponseOnClose() {
final HttpResponsePacket response = responsePacket;
return !response.getProcessingState().isKeepAlive() && !response.isChunked()
Expand Down Expand Up @@ -860,23 +875,71 @@ public Object type() {
}
} // END GracefulCloseEvent

/**
* The TLS handshake failure recorded on a connection by
* {@link SwitchingSSLFilter}, so that the reason (an untrusted or
* mismatching server certificate, say) can be reported to whoever waits
* for that connection instead of a bare "connection closed".
*/
static final Attribute<Throwable> HANDSHAKE_FAILURE = Grizzly.DEFAULT_ATTRIBUTE_BUILDER
.createAttribute(SwitchingSSLFilter.class.getName() + ".handshakeFailure");

static Throwable getHandshakeFailure(final Connection<?> connection) {
return HANDSHAKE_FAILURE.get(connection);
}

static final class SwitchingSSLFilter extends SSLFilter {

private final boolean secureByDefault;
private final boolean hostnameVerification;
final Attribute<Boolean> CONNECTION_IS_SECURE = Grizzly.DEFAULT_ATTRIBUTE_BUILDER
.createAttribute(SwitchingSSLFilter.class.getName());

// -------------------------------------------------------- Constructors

SwitchingSSLFilter(final SSLEngineConfigurator clientConfig, final boolean secureByDefault) {
SwitchingSSLFilter(final SSLEngineConfigurator clientConfig, final boolean secureByDefault,
final boolean hostnameVerification) {

super(null, clientConfig);
this.secureByDefault = secureByDefault;
this.hostnameVerification = hostnameVerification;

}

// ---------------------------------------------- Methods from SSLFilter

/**
* Creates the engine for the server named in the remote URI (not for
* the proxy the socket may be connected to) and, unless switched off,
* has JSSE check the server certificate against that host during the
* handshake: an {@code SSLEngine} does not do this on its own.
*/
@Override
protected SSLEngine createClientSSLEngine(final SSLConnectionContext sslCtx,
final SSLEngineConfigurator sslEngineConfigurator) {
final RemoteConnectionContext context =
RemoteConnectionContext.get(sslCtx.getConnection());
final String host = context != null ? context.getHost() : null;
final SSLEngine sslEngine =
host != null ? sslEngineConfigurator.createSSLEngine(host, -1) : super
.createClientSSLEngine(sslCtx, sslEngineConfigurator);
if (hostnameVerification) {
final SSLParameters parameters = sslEngine.getSSLParameters();
parameters.setEndpointIdentificationAlgorithm("HTTPS");
sslEngine.setSSLParameters(parameters);
}
return sslEngine;
}

@Override
protected void notifyHandshakeFailed(final Connection connection, final Throwable t) {
HANDSHAKE_FAILURE.set(connection, t);
final RemoteConnectionContext context = RemoteConnectionContext.get(connection);
logger.warn("TLS handshake with connector server {0} failed: {1}",
context != null ? context.getAuthority() : connection.getPeerAddress(), t);
super.notifyHandshakeFailed(connection, t);
}

@Override
public NextAction handleEvent(FilterChainContext ctx, FilterChainEvent event)
throws IOException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* with the fields enclosed by brackets [] replaced by
* your own identifying information:
* "Portions Copyrighted [year] [name of copyright owner]"
* Portions Copyrighted 2026 3A Systems, LLC
*/

package org.forgerock.openicf.framework.client;
Expand All @@ -32,6 +33,15 @@

public class ConnectionManagerConfig {

/**
* System property that turns off TLS hostname verification for every
* client unless a {@link ConnectionManagerConfig} says otherwise. Shared
* with the legacy connector server client. Only the literal {@code false}
* disables verification, so a typo in the value can not weaken it.
*/
public static final String HOSTNAME_VERIFICATION_PROPERTY =
"org.identityconnectors.framework.remote.hostnameVerification";

// SSL Config

private String trustStoreProvider;
Expand Down Expand Up @@ -72,6 +82,9 @@ public class ConnectionManagerConfig {

protected int maxConnectionLifeTimeInMs;

protected boolean hostnameVerification =
!"false".equalsIgnoreCase(System.getProperty(HOSTNAME_VERIFICATION_PROPERTY));

public int getScheduledThreadPoolSize() {
return 5;
}
Expand Down Expand Up @@ -175,6 +188,21 @@ public static Builder newBuilder() {
return new Builder();
}

/**
* Whether the connector server certificate is checked against the host of
* the remote URI during the TLS handshake (RFC 2818 / RFC 6125 "HTTPS"
* endpoint identification). On by default; switching it off leaves the
* connection open to man-in-the-middle attacks by anyone holding a
* certificate the client trusts.
*/
public boolean isHostnameVerification() {
return hostnameVerification;
}

public void setHostnameVerification(boolean hostnameVerification) {
this.hostnameVerification = hostnameVerification;
}

public static class Builder {

}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/*
* The contents of this file are subject to the terms of the Common Development and
* Distribution License (the License). You may not use this file except in compliance with the
* License.
*
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
* specific language governing permission and limitations under the License.
*
* When distributing Covered Software, include this CDDL Header Notice in each file and include
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2026 3A Systems, LLC.
*/
package org.forgerock.openicf.framework.server;

import static org.forgerock.openicf.framework.AsyncConnectorInfoManagerTestBase.JSK_PASSWORD;
import static org.forgerock.openicf.framework.AsyncConnectorInfoManagerTestBase.KEY_HASH;
import static org.forgerock.openicf.framework.AsyncConnectorInfoManagerTestBase.TEST_CONNECTOR_KEY;
import static org.forgerock.openicf.framework.AsyncConnectorInfoManagerTestBase.buildRemoteWSFrameworkConnectionInfo;
import static org.forgerock.openicf.framework.AsyncConnectorInfoManagerTestBase.findFreePort;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.fail;

import java.net.URL;
import java.net.URLDecoder;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;

import javax.net.ssl.SSLHandshakeException;

import org.forgerock.openicf.framework.ConnectorFramework;
import org.forgerock.openicf.framework.ConnectorFrameworkFactory;
import org.forgerock.openicf.framework.client.ClientRemoteConnectorInfoManager;
import org.forgerock.openicf.framework.client.ConnectionManager;
import org.forgerock.openicf.framework.client.ConnectionManagerConfig;
import org.forgerock.openicf.framework.client.RemoteWSFrameworkConnectionInfo;
import org.forgerock.openicf.framework.remote.ReferenceCountedObject;
import org.glassfish.grizzly.http.server.NetworkListener;
import org.glassfish.grizzly.ssl.SSLContextConfigurator;
import org.identityconnectors.framework.api.ConnectorInfo;
import org.identityconnectors.framework.common.exceptions.ConnectorException;
import org.identityconnectors.testconnector.TstConnector;
import org.testng.annotations.AfterTest;
import org.testng.annotations.BeforeTest;
import org.testng.annotations.Test;

/**
* The WebSocket client must check the connector server certificate against
* the host of the remote URI. {@code serverKeystore.jks} names
* {@code localhost}, {@code 127.0.0.1} and {@code ::1} as subjectAltName;
* {@code serverKeystore-cn-only.jks} only has {@code CN=localhost}. Both are
* trusted by {@code truststore.jks}, which the client picks up through the
* {@code javax.net.ssl.trustStore} system properties.
*/
public class ClientHostnameVerificationTest {

private static final long TIMEOUT_SECONDS = 30;

private final int sanPort = findFreePort();
private final int cnOnlyPort = findFreePort();

private ConnectorServer connectorServer;

/**
* Like the other tests of this module, the server lives for the whole
* run: every {@link ConnectorServer} registers with the JVM-wide Grizzly
* {@code WebSocketEngine}, and unregistering one while other test
* servers are still in use leaves their WebSocket upgrades unanswered.
*/
@BeforeTest
public void startServer() throws Exception {
String truststore = resourcePath("truststore.jks");
System.setProperty(SSLContextConfigurator.TRUST_STORE_FILE, truststore);
System.setProperty(SSLContextConfigurator.TRUST_STORE_PASSWORD, JSK_PASSWORD);

connectorServer = new ConnectorServer();
connectorServer.setConnectorFrameworkFactory(new ConnectorFrameworkFactory());
connectorServer.setConnectorBundleURLs(Arrays.asList(TstConnector.class
.getProtectionDomain().getCodeSource().getLocation()));
connectorServer.init();
connectorServer.addListener("san", NetworkListener.DEFAULT_NETWORK_HOST, sanPort,
serverSSLContext("serverKeystore.jks", truststore));
connectorServer.addListener("cn-only", NetworkListener.DEFAULT_NETWORK_HOST, cnOnlyPort,
serverSSLContext("serverKeystore-cn-only.jks", truststore));
connectorServer.setKeyHash(KEY_HASH);
connectorServer.start();
}

@AfterTest
public void stopServer() throws Exception {
connectorServer.stop();
connectorServer.destroy();
}

@Test
public void rejectsServerCertificateWithoutMatchingName() throws Exception {
try (Client client = new Client(new ConnectionManagerConfig())) {
try {
client.connect(cnOnlyPort);
fail("Expected the handshake to fail: certificate has no name matching 127.0.0.1");
} catch (ConnectorException e) {
assertHostnameVerificationFailure(e);
}
}
}

@Test
public void acceptsServerCertificateWithMatchingSubjectAltName() throws Exception {
try (Client client = new Client(new ConnectionManagerConfig())) {
assertNotNull(client.connect(sanPort));
}
}

@Test
public void connectsToMismatchedCertificateWhenVerificationIsDisabled() throws Exception {
ConnectionManagerConfig config = new ConnectionManagerConfig();
config.setHostnameVerification(false);
try (Client client = new Client(config)) {
assertNotNull(client.connect(cnOnlyPort));
}
}

// ---- helpers ---------------------------------------------------------

private static void assertHostnameVerificationFailure(ConnectorException e) {
Throwable cause = e;
while (cause != null && !(cause instanceof SSLHandshakeException)) {
cause = cause.getCause();
}
assertNotNull(cause, "Expected an SSLHandshakeException in the cause chain of: " + e);
String message = String.valueOf(cause.getMessage());
assertTrue(message.contains("subject alternative") || message.contains("No name matching"),
"Expected a hostname verification failure, got: " + message);
}

private static SSLContextConfigurator serverSSLContext(String keystore, String truststore)
throws Exception {
SSLContextConfigurator configurator = new SSLContextConfigurator(false);
configurator.setKeyStoreFile(resourcePath(keystore));
configurator.setKeyStorePass(JSK_PASSWORD);
configurator.setTrustStoreFile(truststore);
configurator.setTrustStorePass(JSK_PASSWORD);
configurator.setSecurityProtocol("TLS");
return configurator;
}

private static String resourcePath(String name) throws Exception {
URL url = ClientHostnameVerificationTest.class.getClassLoader().getResource(name);
assertNotNull(url, "missing test resource " + name);
return URLDecoder.decode(url.getFile(), "UTF-8");
}

/** A client-side framework with its own connection manager configuration. */
private static final class Client implements AutoCloseable {
private final ReferenceCountedObject<ConnectorFramework>.Reference framework;

Client(ConnectionManagerConfig config) {
framework = new ConnectorFrameworkFactory().acquire();
framework.get().setConnectionManagerConfig(config);
}

/**
* Opens the WebSocket to the server on {@code port} and, once it is
* up, looks up the test connector over it, so that the initial
* exchange has completed by the time the client is closed.
*/
ConnectorInfo connect(int port) throws Exception {
RemoteWSFrameworkConnectionInfo info =
buildRemoteWSFrameworkConnectionInfo(true, port, null);
ConnectionManager connectionManager =
(ConnectionManager) framework.get().getRemoteConnectionInfoManagerFactory();
ClientRemoteConnectorInfoManager manager = connectionManager.connect(info);
manager.connect().getOrThrow(TIMEOUT_SECONDS, TimeUnit.SECONDS);
return manager.getAsyncConnectorInfoManager().findConnectorInfoAsync(
TEST_CONNECTOR_KEY).getOrThrow(TIMEOUT_SECONDS, TimeUnit.SECONDS);
}

public void close() {
framework.release();
}
}
}
Loading
Loading