September 6, 2019

Missing SNI with Java's HTTPS client

Recently I got strange exceptions connection to HTTPS while developing a Java app. The app connected to my local machine using it’s host name with the Apache HTTP client:

CloseableHttpClient client = HttpClientBuilder.create().build();
String url = "https://gamlor-turboro/files/info.txt";
try (CloseableHttpResponse result = client.execute(new HttpGet(url))){
    String body = EntityUtils.toString(result.getEntity());
    System.out.println(body);
}

When running it I got this strange exception:

Exception in thread "main" javax.net.ssl.SSLPeerUnverifiedException: Certificate for <gamlor-turboro> doesn't match any of the subject alternative names: [api.gamlor.local]
   at org.apache.http.conn.ssl.SSLConnectionSocketFactory.verifyHostname(SSLConnectionSocketFactory.java:507)
   at org.apache.http.conn.ssl.SSLConnectionSocketFactory.createLayeredSocket(SSLConnectionSocketFactory.java:437)
   at org.apache.http.conn.ssl.SSLConnectionSocketFactory.connectSocket(SSLConnectionSocketFactory.java:384)
    ...

The same issue occurs with the native URL.openStream().

String url = "https://gamlor-turboro/files/info.txt"
try (InputStream str = new URL(url).openStream();
     Reader r = new InputStreamReader(str, StandardCharsets.UTF_8);
     BufferedReader bf = new BufferedReader(r)) {
    bf.lines().forEachOrdered(System.out::println);
}

The URL.openStream() return a less informative exception:

Exception in thread "main" javax.net.ssl.SSLHandshakeException: No subject alternative DNS name matching gamlor-turboro found.
   at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:128)
   at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:321)
   at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:264)
   at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:259)
   ...
Caused by: java.security.cert.CertificateException: No subject alternative DNS name matching gamlor-turboro found.
   at java.base/sun.security.util.HostnameChecker.matchDNS(HostnameChecker.java:207)
   at java.base/sun.security.util.HostnameChecker.match(HostnameChecker.java:98)
   at java.base/sun.security.ssl.X509TrustManagerImpl.checkIdentity(X509TrustManagerImpl.java:459)
   at java.base/sun.security.ssl.X509TrustManagerImpl.checkIdentity(X509TrustManagerImpl.java:434)

This looked to me like I misconfigured my test server. The server does serve various domains, including gamlor-turboro and api.gamlor.local. After checking the server configuration and not finding any obvious mistakes I checked how browser behaved. All browsers accessed the URL just fine, yes even IE was happy.

Browsers Working
Figure 1. Browsers Working

OK, browsers are fine so it is specific to the Java client. I was still strange because the Java client worked fine in the past. After a while, I noticed that the Java client worked when I changed to another domain. For example gamlor-turboro.local worked. So it seemed to depend on the domain.

I still assumed that I misconfigured the server and therefore started to debug the server. I had to find out why it sends the wrong certificates to the Java client but the right ones to the browser.

After a debugging I noticed that the Java client doesn’t send any SNI (Server Name Identification) information but browsers do send it. Without SNI info the server picks one of the domains and sends that certificate. That seemed strange to me since SNI seemed to work fine before. I tried other working domains and the SNI information arrived. Only for my 'gamlor-turboro' no SNI information was sent.

After this, I assumed that I somehow misconfigured the Java client and debugged the client-side. After drilling down into the Java TLS implementation I found the issue in sun.security.ssl.Utilities.rawToSNIHostName(source-code) which returns the SNI name for a hostname. And here is the catch, it doesn’t return any SNI name for a hostname without a dot. Any local hostname does not get an SNI name.

sun.security.ssl.Utilities.java
private static SNIHostName rawToSNIHostName(String hostname) {
    SNIHostName sniHostName = null;
    if (hostname != null && hostname.indexOf('.') > 0 && // Only hosts without a dot are skipped and don't get a SNI name
            !hostname.endsWith(".") &&
            !IPAddressUtil.isIPv4LiteralAddress(hostname) &&
            !IPAddressUtil.isIPv6LiteralAddress(hostname)) { // (2)

        try {
            sniHostName = new SNIHostName(hostname);
        } catch (IllegalArgumentException iae) {
            // don't bother to handle illegal host_name
            if (SSLLogger.isOn && SSLLogger.isOn("ssl")) {
                 SSLLogger.fine(hostname + "\" " +
                    "is not a legal HostName for  server name indication");
            }
        }
    }

    return sniHostName;
}

There we have it. The reason I got a domain mismatch is that Java doesn’t send the SNI information for a hostname without a dot like 'gamlor-turboro'. It only sends SNI for a domain looking host. It is not a configuration error.

Java’s SNI is picky
Figure 2. Java’s SNI is picky

My workaround was simple, I just used 'gamlor.local' as hostname instead:

CloseableHttpClient client = HttpClientBuilder.create().build();
// Fixed, using a domain with a dot
String url = "https://gamlor.local/files/info.txt";
try (CloseableHttpResponse result = client.execute(new HttpGet(url))){
    String body = EntityUtils.toString(result.getEntity());
    System.out.println(body);
}
Tags: Java