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
@@ -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.
* <p>
* 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<String> 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<String> names = new ArrayList<String>();
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<String> subjectAltNames(X509Certificate certificate, int type) {
List<String> names = new ArrayList<String>();
Collection<List<?>> 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<Rdn> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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<String> REPORTED_MISMATCHES = ConcurrentHashMap.newKeySet();

private Socket socket;
private BinaryObjectSerializer encoder;
private BinaryObjectDeserializer decoder;
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading