From ddac85fbd38edd07f94e802fbbd55300fd7ccc93 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 18 Sep 2026 12:36:25 +0300 Subject: [PATCH] Verify the connector server certificate against the host over the legacy SSL connection RemoteFrameworkConnection wrapped the socket in an SSLSocket, which does not check the server certificate against the host it connects to, so anyone with a certificate the client trusts could sit in the middle and read the connector server key. Enable JSSE "HTTPS" endpoint identification for the handshake (CodeQL java/unsafe-cert-trust, alert #24). The check can be switched off with -Dorg.identityconnectors.framework.remote.hostnameVerification=false; the client then still compares the certificate names with the host and logs a warning naming the certificate's subject and subjectAltName entries, so the certificate can be fixed and the check re-enabled. --- .../remote/CertificateHostnameMatcher.java | 175 ++++++++++++ .../api/remote/RemoteFrameworkConnection.java | 84 +++++- .../RemoteConnectorInfoManagerSSLTests.java | 7 +- .../CertificateHostnameMatcherTests.java | 116 ++++++++ .../RemoteFrameworkConnectionSSLTests.java | 262 ++++++++++++++++++ .../src/test/resources/KeyStore-san.jks | Bin 0 -> 2165 bytes .../src/test/resources/wildcard-san.pem | 20 ++ .../api/RemoteFrameworkConnectionInfo.java | 15 +- 8 files changed, 671 insertions(+), 8 deletions(-) create mode 100644 OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcher.java create mode 100644 OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/CertificateHostnameMatcherTests.java create mode 100644 OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/framework/impl/api/remote/RemoteFrameworkConnectionSSLTests.java create mode 100644 OpenICF-java-framework/connector-framework-internal/src/test/resources/KeyStore-san.jks create mode 100644 OpenICF-java-framework/connector-framework-internal/src/test/resources/wildcard-san.pem 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: + *

+ */ +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 0000000000000000000000000000000000000000..04d6902c2a9625dd3726c4d9a56291279a7b09b6 GIT binary patch literal 2165 zcmb7FXHb)i63v$c5_&?wh@pj|p@&F^OVLmw9Rz7nR6sNoDT)Gu1PDDsK&eWNg^?zx zAYAZ*a6!ryq7>;O0)kZO0Uq9YGta+oXZFw8ncbap&g}00*#7|nfxw3e{F`v!+aCVD z7q9vq0tMB5-wQw>=sunb-NEy6aw~8GP(U7x1OO-%Iy(q;$AykHes0OwH)t+W5fLLr z9v>$Q{=+WXxDcx`FT!vIx+ z%1l2WH(wilc=qvt%p|pq!O(26PMChnZ^q)7f7cL(vD~YqnzD9@WM2R8b?%}yd9RVA za*FPA%7E{@>%vPBJ-Ta|CvK0ob&(|M3;%(59ac{5$I0~UdflfZU ztn5d{b$(JI<#uPjV`huw+%XL1Y7S<@*}cDeFAMPww(_n^tJBxOpeZk8a{Q(<4SmY` za|^f}Lt3q*RI1fsPT$0h_=`t(ov}FeIe-T@`#d0xMiY3$xNII zcRTQq8MyoWfyS*lWC&T2ckXPSuY@&M4S(ZQp4-DqMyNGKicYw|jN#p|lQQK1R`>q1 ziuIxn3KJcX+I6pcv9`O@%36=-hS|lKX!7nQqw`0Y{z?j_;p}4@qX$jhp%XQ?pGl!A zN7B#Csc5B&ER7KY;9IQx{S=`>x1x5MX{$unS#uKNm0p7TI`?(%S_PwuUt|6lCXls zMgoWV2}H7#t=azRH8(*xaU- z4E*hk?wAzT5i@a2|6tYdqvAb!y{4ey-irHBNo2cQNdoSw7C*&VavIq*=(7bcDdG}z zF?E`lkcs3WZ&Q=EqbbAAu4nI@(D&lQF!GBJB-FB=m3uOfrBAU-yNPyX77j=-bVSzp zgaSG3LU+fvoxp-)UK$|_@P{F}D?~5&t)$L$qO1b`otF?AZ0mx+RntmmchvOtju;6} z_a^G*8gSmH6&B#N_c>DyVJ%ElI|H3yON#m`IT;X3q_Jn&D64$fMuUxE9`_< zGYk#X<@A>???c}D1TeEF<9^ngd(jao$WitrTP6Qol@0Qn%w^{IV)!-$N|YkJq{ z&aH=$^=6EE54dC68V@zIP2FaC=UZKbPQ__}WUf zrL`y;BC#>}6b$laX3|<58Rt2Pij+*ZR7rAL+D- zWUyyYu&N*)@e78axP1ICU-a?z4+_RhqWK6Kcx_cJyf%TLs_k-AfPh#31>pY+{D1AJ zfYk5n0309)6%YX7sQ@>a3IL#76?CBXxcc4j2~h%KnX;-_kw^M*zCv!PzlJ8a&a?nu zs+bSYuB52wGW(?BLXxFFi};saiu(gb)VQfFCm^EtwlAQMb=0)o#^TY7{4}Zb`R)+p zj9pKpF^m*yI3=8NL2uRPJxZynM#srmxD91yR}wwRu)m_H4h)DHJa+0s^DeM}msz{5 zHB3$MYyPs*z?4l?<+3M4J4pkpWii}7BDr~mj1q8JH!}T`Fy_=NAFo_?lGRw2FtDOl zpRW{E%uy{g?*rO|CCO>QRt&jH*(^UNlJ7=!g80K2NBXWw zPbaD-rBeZyPkcEy3Wfk6px7F3i5EMRLX01J1S+&g2t1O zzsg9cEMA)WKeghZk%!+g4smFV-vjua@iA}DRkxYL?R)a;axmA9SdejTk>ws}_^ODBf?n^(f zL9+<<9$SH-!R6&F(fvl>4`fOABtk<+lqwq=2hFn46{~Pv``f2IB9tD(O)}z~eBO2R z#C_@?j-h)Jsditv7t4})lR8%dCUgkPuI`n4(&JlNMLAxj2b#)Fu_&qGYhp3pFF_vO z)Eny`i_LH220faXE{eoaPc{4U`hcI#N&+=-rI>X(NG;u}eJY~%V-%K;mOogC_@Fr; zPD0#_w`w6hZOWdr@t-