diff --git a/jetty-client/src/test/java/org/eclipse/jetty/client/HttpClientTLSTest.java b/jetty-client/src/test/java/org/eclipse/jetty/client/HttpClientTLSTest.java index 057f3925c85..0fa20bb028f 100644 --- a/jetty-client/src/test/java/org/eclipse/jetty/client/HttpClientTLSTest.java +++ b/jetty-client/src/test/java/org/eclipse/jetty/client/HttpClientTLSTest.java @@ -57,6 +57,7 @@ import org.eclipse.jetty.server.SecureRequestCustomizer; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.toolchain.test.Net; import org.eclipse.jetty.util.StringUtil; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.ExecutorThreadPool; @@ -73,6 +74,7 @@ import static org.hamcrest.Matchers.instanceOf; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -940,4 +942,81 @@ public class HttpClientTLSTest // The HTTP request will resume and be forced to handle the TLS buffer expansion. assertTrue(responseLatch.await(5, TimeUnit.SECONDS)); } + + @Test + public void testDefaultNonDomainSNI() throws Exception + { + SslContextFactory.Server serverTLS = new SslContextFactory.Server(); + serverTLS.setKeyStorePath("src/test/resources/keystore_sni_non_domain.p12"); + serverTLS.setKeyStorePassword("storepwd"); + serverTLS.setSNISelector((keyType, issuers, session, sniHost, certificates) -> + { + // Java clients don't send SNI by default if it's not a domain. + assertNull(sniHost); + return serverTLS.sniSelect(keyType, issuers, session, sniHost, certificates); + }); + startServer(serverTLS, new EmptyServerHandler()); + + SslContextFactory.Client clientTLS = new SslContextFactory.Client(); + // Trust any certificate received by the server. + clientTLS.setTrustStorePath("src/test/resources/keystore_sni_non_domain.p12"); + clientTLS.setTrustStorePassword("storepwd"); + // Disable TLS-level hostName verification, as we may receive a random certificate. + clientTLS.setEndpointIdentificationAlgorithm(null); + startClient(clientTLS); + + // Host is "localhost" which is not a domain, so the JDK won't send SNI. + ContentResponse response = client.newRequest("localhost", connector.getLocalPort()) + .scheme(HttpScheme.HTTPS.asString()) + .send(); + + assertEquals(HttpStatus.OK_200, response.getStatus()); + } + + @Test + public void testForcedNonDomainSNI() throws Exception + { + SslContextFactory.Server serverTLS = new SslContextFactory.Server(); + serverTLS.setKeyStorePath("src/test/resources/keystore_sni_non_domain.p12"); + serverTLS.setKeyStorePassword("storepwd"); + serverTLS.setSNISelector((keyType, issuers, session, sniHost, certificates) -> + { + // We have forced the client to send the non-domain SNI. + assertNotNull(sniHost); + return serverTLS.sniSelect(keyType, issuers, session, sniHost, certificates); + }); + startServer(serverTLS, new EmptyServerHandler()); + + SslContextFactory.Client clientTLS = new SslContextFactory.Client(); + // Trust any certificate received by the server. + clientTLS.setTrustStorePath("src/test/resources/keystore_sni_non_domain.p12"); + clientTLS.setTrustStorePassword("storepwd"); + // Force TLS-level hostName verification, as we want to receive the correspondent certificate. + clientTLS.setEndpointIdentificationAlgorithm("HTTPS"); + startClient(clientTLS); + + clientTLS.setSNIProvider(SslContextFactory.Client.SniProvider.NON_DOMAIN_SNI_PROVIDER); + + // Send a request with SNI "localhost", we should get the certificate at alias=localhost. + ContentResponse response1 = client.newRequest("localhost", connector.getLocalPort()) + .scheme(HttpScheme.HTTPS.asString()) + .send(); + assertEquals(HttpStatus.OK_200, response1.getStatus()); + + // Send a request with SNI "127.0.0.1", we should get the certificate at alias=ip. + ContentResponse response2 = client.newRequest("127.0.0.1", connector.getLocalPort()) + .scheme(HttpScheme.HTTPS.asString()) + .send(); + assertEquals(HttpStatus.OK_200, response2.getStatus()); + + if (Net.isIpv6InterfaceAvailable()) + { + // Send a request with SNI "[::1]", we should get the certificate at alias=ip. + ContentResponse response3 = client.newRequest("[::1]", connector.getLocalPort()) + .scheme(HttpScheme.HTTPS.asString()) + .send(); + + assertEquals(HttpStatus.OK_200, response3.getStatus()); + } + } } diff --git a/jetty-client/src/test/resources/jetty-logging.properties b/jetty-client/src/test/resources/jetty-logging.properties index 2bbfa1a3add..ab5ca3d9727 100644 --- a/jetty-client/src/test/resources/jetty-logging.properties +++ b/jetty-client/src/test/resources/jetty-logging.properties @@ -4,3 +4,4 @@ #org.eclipse.jetty.io.SocketChannelEndPoint.LEVEL=DEBUG #org.eclipse.jetty.io.ssl.LEVEL=DEBUG #org.eclipse.jetty.http.LEVEL=DEBUG +#org.eclipse.jetty.util.ssl.LEVEL=DEBUG diff --git a/jetty-client/src/test/resources/keystore_sni_non_domain.p12 b/jetty-client/src/test/resources/keystore_sni_non_domain.p12 new file mode 100644 index 00000000000..0ded97ee3f3 Binary files /dev/null and b/jetty-client/src/test/resources/keystore_sni_non_domain.p12 differ diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/SniX509ExtendedKeyManager.java b/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/SniX509ExtendedKeyManager.java index 2c55cdec1f8..eae0383c110 100644 --- a/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/SniX509ExtendedKeyManager.java +++ b/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/SniX509ExtendedKeyManager.java @@ -196,7 +196,7 @@ public class SniX509ExtendedKeyManager extends X509ExtendedKeyManager if (delegate) alias = _delegate.chooseServerAlias(keyType, issuers, socket); if (LOG.isDebugEnabled()) - LOG.debug("Chose {} alias {}/{} on {}", delegate ? "delegate" : "explicit", alias, keyType, socket); + LOG.debug("Chose {} alias={} keyType={} on {}", delegate ? "delegate" : "explicit", String.valueOf(alias), keyType, socket); return alias; } @@ -210,7 +210,7 @@ public class SniX509ExtendedKeyManager extends X509ExtendedKeyManager if (delegate) alias = _delegate.chooseEngineServerAlias(keyType, issuers, engine); if (LOG.isDebugEnabled()) - LOG.debug("Chose {} alias {}/{} on {}", delegate ? "delegate" : "explicit", alias, keyType, engine); + LOG.debug("Chose {} alias={} keyType={} on {}", delegate ? "delegate" : "explicit", String.valueOf(alias), keyType, engine); return alias; } diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/SslContextFactory.java b/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/SslContextFactory.java index a445cad4790..41f36af0283 100644 --- a/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/SslContextFactory.java +++ b/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/SslContextFactory.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; +import java.nio.charset.StandardCharsets; import java.security.InvalidAlgorithmParameterException; import java.security.KeyStore; import java.security.NoSuchAlgorithmException; @@ -2082,6 +2083,8 @@ public abstract class SslContextFactory extends AbstractLifeCycle implements Dum public static class Client extends SslContextFactory { + private SniProvider sniProvider = (sslEngine, serverNames) -> serverNames; + public Client() { this(false); @@ -2099,6 +2102,89 @@ public abstract class SslContextFactory extends AbstractLifeCycle implements Dum checkEndPointIdentificationAlgorithm(); super.checkConfiguration(); } + + @Override + public void customize(SSLEngine sslEngine) + { + SSLParameters sslParameters = sslEngine.getSSLParameters(); + List serverNames = sslParameters.getServerNames(); + if (serverNames == null) + serverNames = Collections.emptyList(); + List newServerNames = getSNIProvider().apply(sslEngine, serverNames); + if (newServerNames != null && newServerNames != serverNames) + { + sslParameters.setServerNames(newServerNames); + sslEngine.setSSLParameters(sslParameters); + } + super.customize(sslEngine); + } + + /** + * @return the SNI provider used to customize the SNI + */ + public SniProvider getSNIProvider() + { + return sniProvider; + } + + /** + * @param sniProvider the SNI provider used to customize the SNI + */ + public void setSNIProvider(SniProvider sniProvider) + { + this.sniProvider = Objects.requireNonNull(sniProvider); + } + + /** + *

A provider for SNI names to send to the server during the TLS handshake.

+ *

By default, the OpenJDK TLS implementation does not send SNI names when + * they are IP addresses, following what currently specified in + * TLS 1.3, + * or when they are non-domain strings such as {@code "localhost"}.

+ *

If you need to send custom SNI, such as a non-domain SNI or an IP address SNI, + * you can set your own SNI provider or use {@link #NON_DOMAIN_SNI_PROVIDER}.

+ */ + @FunctionalInterface + public interface SniProvider + { + /** + *

An SNI provider that, if the given {@code serverNames} list is empty, + * retrieves the host via {@link SSLEngine#getPeerHost()}, converts it to + * ASCII bytes, and sends it as SNI.

+ *

This allows to send non-domain SNI such as {@code "localhost"} or + * IP addresses.

+ */ + public static final SniProvider NON_DOMAIN_SNI_PROVIDER = Client::getSniServerNames; + + /** + *

Provides the SNI names to send to the server.

+ *

Currently, RFC 6066 allows for different types of server names, + * but defines only one of type "host_name".

+ *

As such, the input {@code serverNames} list and the list to be returned + * contain at most one element.

+ * + * @param sslEngine the SSLEngine that processes the TLS handshake + * @param serverNames the non-null immutable list of server names computed by implementation + * @return either the same {@code serverNames} list passed as parameter, or a new list + * containing the server names to send to the server + */ + public List apply(SSLEngine sslEngine, List serverNames); + } + + private static List getSniServerNames(SSLEngine sslEngine, List serverNames) + { + if (serverNames.isEmpty()) + { + String host = sslEngine.getPeerHost(); + if (host != null) + { + // Must use the byte[] constructor, because the character ':' is forbidden when + // using the String constructor (but typically present in IPv6 addresses). + return List.of(new SNIHostName(host.getBytes(StandardCharsets.US_ASCII))); + } + } + return serverNames; + } } @ManagedObject @@ -2235,10 +2321,16 @@ public abstract class SslContextFactory extends AbstractLifeCycle implements Dum @Override public String sniSelect(String keyType, Principal[] issuers, SSLSession session, String sniHost, Collection certificates) { + boolean sniRequired = isSniRequired(); + + if (LOG.isDebugEnabled()) + LOG.debug("Selecting alias: keyType={}, sni={}, sniRequired={}, certs={}", keyType, String.valueOf(sniHost), sniRequired, certificates); + + String alias; if (sniHost == null) { // No SNI, so reject or delegate. - return isSniRequired() ? null : SniX509ExtendedKeyManager.SniSelector.DELEGATE; + alias = sniRequired ? null : SniX509ExtendedKeyManager.SniSelector.DELEGATE; } else { @@ -2254,19 +2346,26 @@ public abstract class SslContextFactory extends AbstractLifeCycle implements Dum // SNI, as we will likely be called again with a different keyType. boolean anyMatching = aliasCerts().values().stream() .anyMatch(x509 -> x509.matches(sniHost)); - return isSniRequired() || anyMatching ? null : SniX509ExtendedKeyManager.SniSelector.DELEGATE; + alias = sniRequired || anyMatching ? null : SniX509ExtendedKeyManager.SniSelector.DELEGATE; + } + else + { + alias = matching.get(0).getAlias(); + if (matching.size() > 1) + { + // Prefer strict matches over wildcard matches. + alias = matching.stream() + .min(Comparator.comparingInt(cert -> cert.getWilds().size())) + .map(X509::getAlias) + .orElse(alias); + } } - - String alias = matching.get(0).getAlias(); - if (matching.size() == 1) - return alias; - - // Prefer strict matches over wildcard matches. - return matching.stream() - .min(Comparator.comparingInt(cert -> cert.getWilds().size())) - .map(X509::getAlias) - .orElse(alias); } + + if (LOG.isDebugEnabled()) + LOG.debug("Selected alias={}", String.valueOf(alias)); + + return alias; } protected X509ExtendedKeyManager newSniX509ExtendedKeyManager(X509ExtendedKeyManager keyManager) diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/X509.java b/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/X509.java index e5f813b73d6..709047243bb 100644 --- a/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/X509.java +++ b/jetty-util/src/main/java/org/eclipse/jetty/util/ssl/X509.java @@ -20,6 +20,7 @@ import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.regex.Pattern; import javax.naming.ldap.LdapName; import javax.naming.ldap.Rdn; import javax.security.auth.x500.X500Principal; @@ -40,6 +41,10 @@ public class X509 */ private static final int SUBJECT_ALTERNATIVE_NAMES__DNS_NAME = 2; private static final int SUBJECT_ALTERNATIVE_NAMES__IP_ADDRESS = 7; + private static final String IPV4 = "([0-9]{1,3})(\\.[0-9]{1,3}){3}"; + private static final Pattern IPV4_REGEXP = Pattern.compile("^" + IPV4 + "$"); + // Look-ahead for 2 ':' + IPv6 characters + optional IPv4 at the end. + private static final Pattern IPV6_REGEXP = Pattern.compile("(?=.*:.*:)^([0-9a-fA-F:\\[\\]]+)(:" + IPV4 + ")?$"); public static boolean isCertSign(X509Certificate x509) { @@ -182,21 +187,32 @@ public class X509 return true; } - InetAddress address = toInetAddress(host); - if (address != null) - return _addresses.contains(address); + // Check if the host looks like an IP address to avoid + // DNS lookup for host names that did not match. + if (seemsIPAddress(host)) + { + InetAddress address = toInetAddress(host); + if (address != null) + return _addresses.contains(address); + } return false; } + private static boolean seemsIPAddress(String host) + { + return IPV4_REGEXP.matcher(host).matches() || IPV6_REGEXP.matcher(host).matches(); + } + @Override public String toString() { - return String.format("%s@%x(%s,h=%s,w=%s)", + return String.format("%s@%x(%s,h=%s,a=%s,w=%s)", getClass().getSimpleName(), hashCode(), _alias, _hosts, + _addresses, _wilds); } }