diff --git a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcher.java b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcher.java
new file mode 100644
index 00000000..e51053ae
--- /dev/null
+++ b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcher.java
@@ -0,0 +1,175 @@
+/*
+ * 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.identityconnectors.framework.impl.api.remote;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+import javax.naming.InvalidNameException;
+import javax.naming.ldap.LdapName;
+import javax.naming.ldap.Rdn;
+import javax.security.auth.x500.X500Principal;
+
+/**
+ * Matches a host name or IP address against the names in an X.509 server
+ * certificate the way RFC 6125 / RFC 2818 (and JSSE's "HTTPS" endpoint
+ * identification) do: IP addresses against subjectAltName iPAddress entries,
+ * host names against subjectAltName dNSName entries, falling back to the
+ * subject CN only when the certificate carries no dNSName at all.
+ *
+ * This is a diagnostic aid for deployments that switched hostname
+ * verification off; the enforcing check is done by JSSE during the handshake.
+ */
+final class CertificateHostnameMatcher {
+
+ private static final int SAN_DNS_NAME = 2;
+ private static final int SAN_IP_ADDRESS = 7;
+ private static final Pattern IPV4_LITERAL = Pattern.compile("(\\d{1,3}\\.){3}\\d{1,3}");
+
+ private CertificateHostnameMatcher() {
+ }
+
+ /**
+ * Returns whether {@code host} (a DNS name or an IP literal) is one of the
+ * names the certificate was issued for.
+ */
+ static boolean matches(String host, X509Certificate certificate) {
+ if (isIpLiteral(host)) {
+ for (String address : subjectAltNames(certificate, SAN_IP_ADDRESS)) {
+ if (sameAddress(host, address)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ List dnsNames = subjectAltNames(certificate, SAN_DNS_NAME);
+ if (dnsNames.isEmpty()) {
+ String commonName = commonName(certificate);
+ return commonName != null && matchesDnsName(host, commonName);
+ }
+ for (String dnsName : dnsNames) {
+ if (matchesDnsName(host, dnsName)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Describes the subject and subjectAltName entries of the certificate for
+ * log messages, e.g.
+ * {@code subject 'CN=localhost', subjectAltName [dns:localhost, ip:127.0.0.1]}.
+ */
+ static String describe(X509Certificate certificate) {
+ StringBuilder description = new StringBuilder("subject '")
+ .append(certificate.getSubjectX500Principal().getName(X500Principal.RFC2253))
+ .append('\'');
+ List names = new ArrayList();
+ for (String dnsName : subjectAltNames(certificate, SAN_DNS_NAME)) {
+ names.add("dns:" + dnsName);
+ }
+ for (String address : subjectAltNames(certificate, SAN_IP_ADDRESS)) {
+ names.add("ip:" + address);
+ }
+ if (names.isEmpty()) {
+ description.append(", no subjectAltName");
+ } else {
+ description.append(", subjectAltName ").append(names);
+ }
+ return description.toString();
+ }
+
+ private static boolean isIpLiteral(String host) {
+ return host.indexOf(':') >= 0 || IPV4_LITERAL.matcher(host).matches();
+ }
+
+ private static boolean sameAddress(String host, String address) {
+ String literal = host;
+ if (literal.startsWith("[") && literal.endsWith("]")) {
+ literal = literal.substring(1, literal.length() - 1);
+ }
+ try {
+ // both operands are literals, so no name resolution happens here
+ return InetAddress.getByName(literal).equals(InetAddress.getByName(address));
+ } catch (UnknownHostException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Case-insensitive comparison; a {@code *} in the leftmost label of the
+ * certificate name stands for exactly one label of the host.
+ */
+ private static boolean matchesDnsName(String host, String name) {
+ String lowerHost = host.toLowerCase(Locale.ENGLISH);
+ String lowerName = name.toLowerCase(Locale.ENGLISH);
+ if (!lowerName.startsWith("*.")) {
+ return lowerHost.equals(lowerName);
+ }
+ String suffix = lowerName.substring(1);
+ if (suffix.indexOf('.', 1) < 0) {
+ // "*.com": a wildcard must not cover a whole top-level domain
+ return false;
+ }
+ int firstDot = lowerHost.indexOf('.');
+ return firstDot > 0 && lowerHost.substring(firstDot).equals(suffix);
+ }
+
+ private static List subjectAltNames(X509Certificate certificate, int type) {
+ List names = new ArrayList();
+ Collection> entries;
+ try {
+ entries = certificate.getSubjectAlternativeNames();
+ } catch (CertificateParsingException e) {
+ return names;
+ }
+ if (entries == null) {
+ return names;
+ }
+ for (List> entry : entries) {
+ if (entry.size() >= 2 && Integer.valueOf(type).equals(entry.get(0))
+ && entry.get(1) instanceof String) {
+ names.add((String) entry.get(1));
+ }
+ }
+ return names;
+ }
+
+ /** The most specific CN of the subject, or {@code null}. */
+ private static String commonName(X509Certificate certificate) {
+ String subject = certificate.getSubjectX500Principal().getName(X500Principal.RFC2253);
+ try {
+ List rdns = new LdapName(subject).getRdns();
+ // RFC 2253 lists the most specific RDN first, LdapName stores it last
+ for (int i = rdns.size() - 1; i >= 0; i--) {
+ Rdn rdn = rdns.get(i);
+ if ("CN".equalsIgnoreCase(rdn.getType()) && rdn.getValue() instanceof String) {
+ return (String) rdn.getValue();
+ }
+ }
+ } catch (InvalidNameException e) {
+ // fall through: no usable CN
+ }
+ return null;
+ }
+}
diff --git a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnection.java b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnection.java
index e19c570d..3f723b4e 100644
--- a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnection.java
+++ b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnection.java
@@ -19,6 +19,7 @@
* enclosed by brackets [] replaced by your own identifying information:
* "Portions Copyrighted [year] [name of copyright owner]"
* ====================
+ * Portions Copyrighted 2026 3A Systems, LLC
*/
package org.identityconnectors.framework.impl.api.remote;
@@ -28,9 +29,15 @@
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
@@ -46,6 +53,21 @@
public class RemoteFrameworkConnection implements Closeable {
private static final Log LOG = Log.getLog(RemoteFrameworkConnection.class);
+
+ /**
+ * System property controlling whether the connector server certificate is
+ * checked against the configured host when {@code useSSL} is on
+ * (RFC 2818 / RFC 6125 "HTTPS" endpoint identification). Verification is
+ * on unless the property is set to exactly {@code false}; disabling it
+ * leaves the connection open to man-in-the-middle attacks by anyone
+ * holding a certificate the client trusts.
+ */
+ public static final String HOSTNAME_VERIFICATION_PROPERTY =
+ "org.identityconnectors.framework.remote.hostnameVerification";
+
+ /** Servers already reported as mismatching while verification is off. */
+ private static final Set REPORTED_MISMATCHES = ConcurrentHashMap.newKeySet();
+
private Socket socket;
private BinaryObjectSerializer encoder;
private BinaryObjectDeserializer decoder;
@@ -97,10 +119,22 @@ private void init(RemoteFrameworkConnectionInfo connectionInfo) throws Exception
factory = context.getSocketFactory();
}
- socket =
- factory.createSocket(socket, connectionInfo.getHost(), connectionInfo
- .getPort(), true);
- ((SSLSocket) socket).startHandshake();
+ SSLSocket sslSocket =
+ (SSLSocket) factory.createSocket(socket, connectionInfo.getHost(),
+ connectionInfo.getPort(), true);
+ // SSLSocket does not check the server certificate against the
+ // host on its own: have JSSE do it during the handshake.
+ boolean verifyHostname = isHostnameVerificationEnabled();
+ if (verifyHostname) {
+ SSLParameters parameters = sslSocket.getSSLParameters();
+ parameters.setEndpointIdentificationAlgorithm("HTTPS");
+ sslSocket.setSSLParameters(parameters);
+ }
+ sslSocket.startHandshake();
+ if (!verifyHostname) {
+ reportCertificateMismatch(connectionInfo, sslSocket);
+ }
+ socket = sslSocket;
}
} catch (Exception e) {
try {
@@ -113,6 +147,48 @@ private void init(RemoteFrameworkConnectionInfo connectionInfo) throws Exception
init(socket);
}
+ /**
+ * Only the literal {@code false} disables verification, so that a typo in
+ * the property value can not silently weaken the connection.
+ */
+ static boolean isHostnameVerificationEnabled() {
+ return !"false".equalsIgnoreCase(System.getProperty(HOSTNAME_VERIFICATION_PROPERTY));
+ }
+
+ /**
+ * With verification switched off, still tell the administrator (once per
+ * server) when the certificate would not have passed, so the certificate
+ * can be fixed and verification re-enabled.
+ */
+ private static void reportCertificateMismatch(RemoteFrameworkConnectionInfo connectionInfo,
+ SSLSocket sslSocket) {
+ String server = connectionInfo.getHost() + ":" + connectionInfo.getPort();
+ if (REPORTED_MISMATCHES.contains(server)) {
+ return;
+ }
+ String problem;
+ try {
+ Certificate[] chain = sslSocket.getSession().getPeerCertificates();
+ if (chain.length == 0 || !(chain[0] instanceof X509Certificate)) {
+ problem = "presented no X.509 certificate";
+ } else if (CertificateHostnameMatcher.matches(connectionInfo.getHost(),
+ (X509Certificate) chain[0])) {
+ return;
+ } else {
+ problem = "presented a certificate that does not match the host: "
+ + CertificateHostnameMatcher.describe((X509Certificate) chain[0]);
+ }
+ } catch (SSLPeerUnverifiedException e) {
+ problem = "presented no verifiable certificate";
+ }
+ if (REPORTED_MISMATCHES.add(server)) {
+ LOG.warn("TLS hostname verification is disabled ({0}=false) and connector server {1} {2}."
+ + " The connection is exposed to man-in-the-middle attacks;"
+ + " fix the server certificate and re-enable verification.",
+ HOSTNAME_VERIFICATION_PROPERTY, server, problem);
+ }
+ }
+
private void init(Socket socket) throws Exception {
this.socket = socket;
InputStream inputStream = this.socket.getInputStream();
diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/RemoteConnectorInfoManagerSSLTests.java b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/RemoteConnectorInfoManagerSSLTests.java
index 94eae13c..f73aa9bf 100644
--- a/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/RemoteConnectorInfoManagerSSLTests.java
+++ b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/RemoteConnectorInfoManagerSSLTests.java
@@ -19,6 +19,7 @@
* enclosed by brackets [] replaced by your own identifying information:
* "Portions Copyrighted [year] [name of copyright owner]"
* ====================
+ * Portions Copyrighted 2026 3A Systems, LLC
*/
package org.identityconnectors.framework.impl.api;
@@ -201,10 +202,12 @@ protected ConnectorInfoManager getConnectorInfoManager() throws Exception {
final int PORT = 8761;
+ // The client verifies the server certificate against the host it
+ // connects to, so the certificate must carry 127.0.0.1 as subjectAltName.
TrustManager clientTrustManager =
- new MyTrustManager("KeyStore.jks");
+ new MyTrustManager("KeyStore-san.jks");
KeyManager serverKeyManager =
- new MyKeyManager("KeyStore.jks");
+ new MyKeyManager("KeyStore-san.jks");
synchronized (RemoteConnectorInfoManagerSSLTests.class) {
if (null == _server) {
diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcherTests.java b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcherTests.java
new file mode 100644
index 00000000..40a1725c
--- /dev/null
+++ b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcherTests.java
@@ -0,0 +1,116 @@
+/*
+ * 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.identityconnectors.framework.impl.api.remote;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
+import java.io.InputStream;
+import java.security.KeyStore;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+
+import org.testng.annotations.Test;
+
+/**
+ * Test certificates:
+ *
+ * - {@code KeyStore.jks}: {@code CN=localhost}, no subjectAltName;
+ * - {@code KeyStore-san.jks}: {@code CN=localhost},
+ * {@code SAN=dns:localhost,ip:127.0.0.1,ip:::1};
+ * - {@code wildcard-san.pem}: {@code CN=cn.example.org},
+ * {@code SAN=dns:*.example.com,dns:example.net}.
+ *
+ */
+public class CertificateHostnameMatcherTests {
+
+ @Test
+ public void matchesCommonNameWhenCertificateHasNoSubjectAltName() throws Exception {
+ X509Certificate cert = keyStoreCertificate("KeyStore.jks");
+ assertTrue(CertificateHostnameMatcher.matches("localhost", cert));
+ assertTrue(CertificateHostnameMatcher.matches("LOCALHOST", cert));
+ assertFalse(CertificateHostnameMatcher.matches("example.com", cert));
+ }
+
+ @Test
+ public void neverMatchesIpAddressAgainstCommonName() throws Exception {
+ X509Certificate cert = keyStoreCertificate("KeyStore.jks");
+ assertFalse(CertificateHostnameMatcher.matches("127.0.0.1", cert));
+ }
+
+ @Test
+ public void matchesDnsSubjectAltName() throws Exception {
+ X509Certificate cert = keyStoreCertificate("KeyStore-san.jks");
+ assertTrue(CertificateHostnameMatcher.matches("localhost", cert));
+ assertFalse(CertificateHostnameMatcher.matches("localhost.localdomain", cert));
+ }
+
+ @Test
+ public void matchesIpSubjectAltName() throws Exception {
+ X509Certificate cert = keyStoreCertificate("KeyStore-san.jks");
+ assertTrue(CertificateHostnameMatcher.matches("127.0.0.1", cert));
+ assertTrue(CertificateHostnameMatcher.matches("::1", cert));
+ assertTrue(CertificateHostnameMatcher.matches("0:0:0:0:0:0:0:1", cert));
+ assertFalse(CertificateHostnameMatcher.matches("127.0.0.2", cert));
+ }
+
+ @Test
+ public void ignoresCommonNameWhenSubjectAltNameHasDnsNames() throws Exception {
+ X509Certificate cert = pemCertificate("wildcard-san.pem");
+ assertFalse(CertificateHostnameMatcher.matches("cn.example.org", cert));
+ assertTrue(CertificateHostnameMatcher.matches("example.net", cert));
+ assertTrue(CertificateHostnameMatcher.matches("EXAMPLE.NET", cert));
+ }
+
+ @Test
+ public void matchesWildcardForExactlyOneLeftmostLabel() throws Exception {
+ X509Certificate cert = pemCertificate("wildcard-san.pem");
+ assertTrue(CertificateHostnameMatcher.matches("www.example.com", cert));
+ assertTrue(CertificateHostnameMatcher.matches("WWW.Example.COM", cert));
+ assertFalse(CertificateHostnameMatcher.matches("example.com", cert));
+ assertFalse(CertificateHostnameMatcher.matches("a.b.example.com", cert));
+ assertFalse(CertificateHostnameMatcher.matches("wwwexample.com", cert));
+ }
+
+ @Test
+ public void describeListsSubjectAndSubjectAltNames() throws Exception {
+ String description = CertificateHostnameMatcher.describe(keyStoreCertificate("KeyStore-san.jks"));
+ assertTrue(description.contains("CN=localhost"), description);
+ assertTrue(description.contains("dns:localhost"), description);
+ assertTrue(description.contains("ip:127.0.0.1"), description);
+
+ description = CertificateHostnameMatcher.describe(keyStoreCertificate("KeyStore.jks"));
+ assertTrue(description.contains("CN=localhost"), description);
+ assertFalse(description.contains("dns:"), description);
+ }
+
+ private static X509Certificate keyStoreCertificate(String name) throws Exception {
+ try (InputStream in = CertificateHostnameMatcherTests.class.getResourceAsStream("/" + name)) {
+ assertNotNull(in, "missing test resource " + name);
+ KeyStore store = KeyStore.getInstance("JKS");
+ store.load(in, "changeit".toCharArray());
+ return (X509Certificate) store.getCertificate(store.aliases().nextElement());
+ }
+ }
+
+ private static X509Certificate pemCertificate(String name) throws Exception {
+ try (InputStream in = CertificateHostnameMatcherTests.class.getResourceAsStream("/" + name)) {
+ assertNotNull(in, "missing test resource " + name);
+ return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(in);
+ }
+ }
+}
diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnectionSSLTests.java b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnectionSSLTests.java
new file mode 100644
index 00000000..bd7f70fc
--- /dev/null
+++ b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnectionSSLTests.java
@@ -0,0 +1,262 @@
+/*
+ * 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.identityconnectors.framework.impl.api.remote;
+
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetAddress;
+import java.security.KeyStore;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.List;
+
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLHandshakeException;
+import javax.net.ssl.SSLServerSocket;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
+
+import org.identityconnectors.common.CollectionUtil;
+import org.identityconnectors.common.security.GuardedString;
+import org.identityconnectors.framework.api.RemoteFrameworkConnectionInfo;
+import org.identityconnectors.framework.common.exceptions.ConnectorException;
+import org.testng.annotations.Test;
+
+/**
+ * Verifies that the legacy connector server client checks the server
+ * certificate against the configured host (TLS hostname verification).
+ *
+ * {@code KeyStore.jks} holds a certificate with {@code CN=localhost} and no
+ * subjectAltName; {@code KeyStore-san.jks} holds one with
+ * {@code SAN=dns:localhost,ip:127.0.0.1,ip:::1}.
+ */
+public class RemoteFrameworkConnectionSSLTests {
+
+ private static final char[] PASSWORD = "changeit".toCharArray();
+ private static final int TIMEOUT = 10000;
+
+ @Test
+ public void rejectsServerCertificateWithoutMatchingName() throws Exception {
+ KeyStore store = loadKeyStore("KeyStore.jks");
+ try (TlsServer server = new TlsServer(store, InetAddress.getByName("127.0.0.1"))) {
+ try {
+ connect("127.0.0.1", server.getPort(), trustManagers(store)).close();
+ fail("Expected the handshake to fail: certificate has no name matching 127.0.0.1");
+ } catch (ConnectorException e) {
+ assertHandshakeFailure(e);
+ }
+ }
+ }
+
+ @Test
+ public void verifiesHostnameEvenWithPlainX509TrustManager() throws Exception {
+ KeyStore store = loadKeyStore("KeyStore.jks");
+ try (TlsServer server = new TlsServer(store, InetAddress.getByName("127.0.0.1"))) {
+ try {
+ connect("127.0.0.1", server.getPort(),
+ CollectionUtil. newList(new PinningTrustManager(store)))
+ .close();
+ fail("Expected the handshake to fail: certificate has no name matching 127.0.0.1");
+ } catch (ConnectorException e) {
+ assertHandshakeFailure(e);
+ }
+ }
+ }
+
+ @Test
+ public void acceptsServerCertificateWithMatchingSubjectAltName() throws Exception {
+ KeyStore store = loadKeyStore("KeyStore-san.jks");
+ try (TlsServer server = new TlsServer(store, InetAddress.getByName("127.0.0.1"))) {
+ connect("127.0.0.1", server.getPort(), trustManagers(store)).close();
+ }
+ }
+
+ @Test
+ public void fallsBackToCommonNameWhenCertificateHasNoSubjectAltName() throws Exception {
+ KeyStore store = loadKeyStore("KeyStore.jks");
+ InetAddress localhost = InetAddress.getByName("localhost");
+ try (TlsServer server = new TlsServer(store, localhost)) {
+ connect("localhost", server.getPort(), trustManagers(store)).close();
+ }
+ }
+
+ @Test
+ public void connectsWhenHostnameVerificationIsDisabled() throws Exception {
+ KeyStore store = loadKeyStore("KeyStore.jks");
+ try (TlsServer server = new TlsServer(store, InetAddress.getByName("127.0.0.1"))) {
+ withHostnameVerificationProperty("false", () ->
+ connect("127.0.0.1", server.getPort(), trustManagers(store)).close());
+ }
+ }
+
+ @Test
+ public void keepsVerificationUnlessPropertyIsExactlyFalse() throws Exception {
+ KeyStore store = loadKeyStore("KeyStore.jks");
+ try (TlsServer server = new TlsServer(store, InetAddress.getByName("127.0.0.1"))) {
+ withHostnameVerificationProperty("off", () -> {
+ try {
+ connect("127.0.0.1", server.getPort(), trustManagers(store)).close();
+ fail("A value other than 'false' must not disable hostname verification");
+ } catch (ConnectorException e) {
+ assertHandshakeFailure(e);
+ }
+ });
+ }
+ }
+
+ // ---- helpers ---------------------------------------------------------
+
+ private interface Action {
+ void run() throws Exception;
+ }
+
+ private static void withHostnameVerificationProperty(String value, Action action)
+ throws Exception {
+ String property = RemoteFrameworkConnection.HOSTNAME_VERIFICATION_PROPERTY;
+ String previous = System.getProperty(property);
+ System.setProperty(property, value);
+ try {
+ action.run();
+ } finally {
+ if (previous == null) {
+ System.clearProperty(property);
+ } else {
+ System.setProperty(property, previous);
+ }
+ }
+ }
+
+ private static void assertHandshakeFailure(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 RemoteFrameworkConnection connect(String host, int port,
+ List trustManagers) {
+ return new RemoteFrameworkConnection(new RemoteFrameworkConnectionInfo(host, port,
+ new GuardedString(PASSWORD), true, trustManagers, TIMEOUT));
+ }
+
+ private static KeyStore loadKeyStore(String name) throws Exception {
+ try (InputStream in = RemoteFrameworkConnectionSSLTests.class.getResourceAsStream("/" + name)) {
+ assertNotNull(in, "missing test resource " + name);
+ KeyStore store = KeyStore.getInstance("JKS");
+ store.load(in, PASSWORD);
+ return store;
+ }
+ }
+
+ private static List trustManagers(KeyStore store) throws Exception {
+ TrustManagerFactory factory =
+ TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ factory.init(store);
+ return Arrays.asList(factory.getTrustManagers());
+ }
+
+ /**
+ * A legacy-style trust manager (not an {@code X509ExtendedTrustManager})
+ * that trusts exactly the certificates found in a key store, mirroring
+ * what integrators commonly plug into {@link RemoteFrameworkConnectionInfo}.
+ */
+ private static final class PinningTrustManager implements X509TrustManager {
+ private final KeyStore store;
+
+ PinningTrustManager(KeyStore store) {
+ this.store = store;
+ }
+
+ public void checkClientTrusted(X509Certificate[] chain, String authType)
+ throws CertificateException {
+ checkTrusted(chain);
+ }
+
+ public void checkServerTrusted(X509Certificate[] chain, String authType)
+ throws CertificateException {
+ checkTrusted(chain);
+ }
+
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+
+ private void checkTrusted(X509Certificate[] chain) throws CertificateException {
+ try {
+ if (store.getCertificateAlias(chain[0]) == null) {
+ throw new CertificateException("untrusted certificate");
+ }
+ } catch (CertificateException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new CertificateException(e);
+ }
+ }
+ }
+
+ /**
+ * Minimal TLS server: completes the handshake for every accepted
+ * connection and then waits for the client to hang up.
+ */
+ private static final class TlsServer implements AutoCloseable {
+ private final SSLServerSocket serverSocket;
+
+ TlsServer(KeyStore store, InetAddress bindAddress) throws Exception {
+ KeyManagerFactory factory =
+ KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ factory.init(store, PASSWORD);
+ SSLContext context = SSLContext.getInstance("TLS");
+ context.init(factory.getKeyManagers(), null, null);
+ serverSocket = (SSLServerSocket) context.getServerSocketFactory()
+ .createServerSocket(0, 1, bindAddress);
+ Thread acceptor = new Thread(this::serve, "RemoteFrameworkConnectionSSLTests-server");
+ acceptor.setDaemon(true);
+ acceptor.start();
+ }
+
+ int getPort() {
+ return serverSocket.getLocalPort();
+ }
+
+ private void serve() {
+ while (!serverSocket.isClosed()) {
+ try (SSLSocket socket = (SSLSocket) serverSocket.accept()) {
+ socket.setSoTimeout(TIMEOUT);
+ socket.startHandshake();
+ socket.getInputStream().read();
+ } catch (IOException e) {
+ // handshake rejected by the client or server closed: next connection
+ }
+ }
+ }
+
+ public void close() throws IOException {
+ serverSocket.close();
+ }
+ }
+}
diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/resources/KeyStore-san.jks b/OpenICF-java-framework/connector-framework-internal/src/test/resources/KeyStore-san.jks
new file mode 100644
index 00000000..04d6902c
Binary files /dev/null and b/OpenICF-java-framework/connector-framework-internal/src/test/resources/KeyStore-san.jks differ
diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/resources/wildcard-san.pem b/OpenICF-java-framework/connector-framework-internal/src/test/resources/wildcard-san.pem
new file mode 100644
index 00000000..094c5648
--- /dev/null
+++ b/OpenICF-java-framework/connector-framework-internal/src/test/resources/wildcard-san.pem
@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDLTCCAhWgAwIBAgIJAJiu6UyYAXyyMA0GCSqGSIb3DQEBCwUAMDAxFTATBgNV
+BAoTDE9wZW5JQ0YgdGVzdDEXMBUGA1UEAxMOY24uZXhhbXBsZS5vcmcwIBcNMjYw
+OTE4MDkyNzEyWhgPMjA1NjA5MTAwOTI3MTJaMDAxFTATBgNVBAoTDE9wZW5JQ0Yg
+dGVzdDEXMBUGA1UEAxMOY24uZXhhbXBsZS5vcmcwggEiMA0GCSqGSIb3DQEBAQUA
+A4IBDwAwggEKAoIBAQDZMsfEKEiuvkf6d3tOmn6ndoMbAF/DGb3bnsvZ9yfgMPqq
+chKHjZigLJwcSCYH84fMTbOJfOVIB+lynIiDcewSyYUzduUr8FYh0Nu2nnQqOFjc
+o9UFZGdGWI5ou9XdSRRlfTzb/JXUiOmHPwQFSHoOWtJ5bQEyXcq4VaQKmHRqlrVN
+UeoiEsoTxh9Hp/x0P/u1vlNOdP1YJioeF5qvSodpmwiuxOXNQsUe2mNcdl5c8pia
+omAm7eznbbst72XF6P7zTt3MtegA8mcf4aCrTaGm8pNmcz/jxGufFqHqlEzxVarb
+dkehZULH2pYMl7YS8NFmL9o0uYyTcI9RrplvR2jfAgMBAAGjSDBGMB0GA1UdDgQW
+BBSMT3EPXPhvdQ0zQkyfu6D5S1Oj9DAlBgNVHREEHjAcgg0qLmV4YW1wbGUuY29t
+ggtleGFtcGxlLm5ldDANBgkqhkiG9w0BAQsFAAOCAQEAZrRnTuCgh/bXQv8dv64t
+fEU5xw9Mts39xi/+8/gC96njrK/ydeZGHQC7bSSgv2c7An7kXw5iESk3vLkg2wnX
+SGGeYyjTDU6kQNo9VI2qSFdIPmgrzwu1Zk+M33PAZMk3X5XEtUaWEi1xBCQo9+K3
+01g9tRWElBpRgaI2WSUu31l1IBkIi7FymJi5KJdaVM0eCTRpFrf8wbuL+7H+bipY
+N+/P5myrYBH1BDBWBco44GuEZe2O8vPngWf+MnLVVJkFHUO5MZueLL5e8Ju//DuB
+7WsdR1yk4pk9VeHUQuWJxyug8JhusWQFzr0Z9UDeEVCe83p2myECCwokPVpmZ0bW
+MA==
+-----END CERTIFICATE-----
diff --git a/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/api/RemoteFrameworkConnectionInfo.java b/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/api/RemoteFrameworkConnectionInfo.java
index 21750764..a0f36591 100644
--- a/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/api/RemoteFrameworkConnectionInfo.java
+++ b/OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/framework/api/RemoteFrameworkConnectionInfo.java
@@ -20,6 +20,7 @@
* "Portions Copyrighted [year] [name of copyright owner]"
* ====================
* Portions Copyrighted 2015 ForgeRock AS.
+ * Portions Copyrighted 2026 3A Systems, LLC
*/
package org.identityconnectors.framework.api;
@@ -69,7 +70,12 @@ public RemoteFrameworkConnectionInfo(String host, int port, GuardedString key) {
* @param key
* The remote framework key
* @param useSSL
- * Set to true if we are to connect via SSL.
+ * Set to true if we are to connect via SSL. The server
+ * certificate is then verified against {@code host}: it must
+ * list it as a subjectAltName dNSName or iPAddress entry (or as
+ * CN when it has no subjectAltName). Setting the system property
+ * {@code org.identityconnectors.framework.remote.hostnameVerification}
+ * to {@code false} disables this check (not recommended).
* @param trustManagers
* List of {@link TrustManager}'s to use for establising the SSL
* connection. May be null or empty, in which case the default
@@ -102,7 +108,12 @@ public RemoteFrameworkConnectionInfo(String host, int port, GuardedString key, b
* @param key
* The remote framework key
* @param useSSL
- * Set to true if we are to connect via SSL.
+ * Set to true if we are to connect via SSL. The server
+ * certificate is then verified against {@code host}: it must
+ * list it as a subjectAltName dNSName or iPAddress entry (or as
+ * CN when it has no subjectAltName). Setting the system property
+ * {@code org.identityconnectors.framework.remote.hostnameVerification}
+ * to {@code false} disables this check (not recommended).
* @param trustManagers
* List of {@link TrustManager}'s to use for establising the SSL
* connection. May be null or empty, in which case the default