Fixes #6276 - Support non-standard domains in SNI and X509. (#6296) (#6311)

* Fixes #6276 - Support non-standard domains in SNI and X509. (#6296)

Improved support for IP addresses in X509 (after #5379).
Introduced SslContextFactory.Client.SniProvider to allow applications to specify the SNI names to send to the server.
Improved logging of SNI processing.
Skip X509 matching over IP addresses when the host does
not look like an IP address, to avoid reverse DNS lookup.

Signed-off-by: Simone Bordet <simone.bordet@gmail.com>
(cherry picked from commit 04df6d4ec2)
This commit is contained in:
Simone Bordet 2021-05-24 16:57:57 +02:00 committed by GitHub
parent aec0128f36
commit cfcb2efa80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 213 additions and 18 deletions

View File

@ -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());
}
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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<SNIServerName> serverNames = sslParameters.getServerNames();
if (serverNames == null)
serverNames = Collections.emptyList();
List<SNIServerName> 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);
}
/**
* <p>A provider for SNI names to send to the server during the TLS handshake.</p>
* <p>By default, the OpenJDK TLS implementation does not send SNI names when
* they are IP addresses, following what currently specified in
* <a href="https://datatracker.ietf.org/doc/html/rfc6066#section-3">TLS 1.3</a>,
* or when they are non-domain strings such as {@code "localhost"}.</p>
* <p>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}.</p>
*/
@FunctionalInterface
public interface SniProvider
{
/**
* <p>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.</p>
* <p>This allows to send non-domain SNI such as {@code "localhost"} or
* IP addresses.</p>
*/
public static final SniProvider NON_DOMAIN_SNI_PROVIDER = Client::getSniServerNames;
/**
* <p>Provides the SNI names to send to the server.</p>
* <p>Currently, RFC 6066 allows for different types of server names,
* but defines only one of type "host_name".</p>
* <p>As such, the input {@code serverNames} list and the list to be returned
* contain at most one element.</p>
*
* @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<SNIServerName> apply(SSLEngine sslEngine, List<SNIServerName> serverNames);
}
private static List<SNIServerName> getSniServerNames(SSLEngine sslEngine, List<SNIServerName> 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<X509> 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)

View File

@ -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);
}
}