今天解决了一个因https url中存在不合法字符导致证书校验失败的问题,错误信息为java.security.cert.CertificateException: Illegal given domain xxx_xx.test.com.cn,网上对于这个问题的解决办法一般都是通过向HttpsURLConnection设置一个自定义的HostnameVerifier禁用证书中的域名校验即可,因为本来这中域名就不合法,如果对方不愿意配合修改域名的话,只能在我方这边关闭域名校验。
本文简单记录一下为什么这么设置可以禁用域名校验,以及这么做的优缺点。
问题现象
今天发现日志中出现大量调对方https服务失败的情况,错误堆栈如下:
Caused by: javax.net.ssl.SSLHandshakeException: java.security.cert.CertificateException: Illegal given domain name: xxx_xx.test.com.cn
at sun.security.ssl.Alerts.getSSLException(Alerts.java:192)
at sun.security.ssl.SSLSocketImpl.fatal(SSLSocketImpl.java:1946)
at sun.security.ssl.Handshaker.fatalSE(Handshaker.java:316)
at sun.security.ssl.Handshaker.fatalSE(Handshaker.java:310)
at sun.security.ssl.ClientHandshaker.serverCertificate(ClientHandshaker.java:1639)
at sun.security.ssl.ClientHandshaker.processMessage(ClientHandshaker.java:223)
at sun.security.ssl.Handshaker.processLoop(Handshaker.java:1037)
at sun.security.ssl.Handshaker.process_record(Handshaker.java:965)
at sun.security.ssl.SSLSocketImpl.readRecord(SSLSocketImpl.java:1064)
at sun.security.ssl.SSLSocketImpl.performInitialHandshake(SSLSocketImpl.java:1367)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1395)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1379)
at sun.net.www.protocol.https.HttpsClient.afterConnect(HttpsClient.java:559)
at sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:185)
at sun.net.www.protocol.http.HttpURLConnection.getOutputStream0(HttpURLConnection.java:1334)
at sun.net.www.protocol.http.HttpURLConnection.getOutputStream(HttpURLConnection.java:1309)
at sun.net.www.protocol.https.HttpsURLConnectionImpl.getOutputStream(HttpsURLConnectionImpl.java:259)
at com.xxx.utils.http.SimpleHttpClient.doRequest(SimpleHttpClient.java:57)
... 91 more
Caused by: java.security.cert.CertificateException: Illegal given domain name: xxx_xx.test.com.cn
at sun.security.util.HostnameChecker.matchDNS(HostnameChecker.java:195)
at sun.security.util.HostnameChecker.match(HostnameChecker.java:96)
at sun.security.ssl.X509TrustManagerImpl.checkIdentity(X509TrustManagerImpl.java:455)
at sun.security.ssl.X509TrustManagerImpl.checkIdentity(X509TrustManagerImpl.java:436)
at sun.security.ssl.X509TrustManagerImpl.checkTrusted(X509TrustManagerImpl.java:200)
at sun.security.ssl.X509TrustManagerImpl.checkServerTrusted(X509TrustManagerImpl.java:124)
at sun.security.ssl.ClientHandshaker.serverCertificate(ClientHandshaker.java:1621)
... 107 more
Caused by: java.lang.IllegalArgumentException: Contains non-LDH ASCII characters
at java.net.IDN.toASCIIInternal(IDN.java:296)
at java.net.IDN.toASCII(IDN.java:122)
at javax.net.ssl.SNIHostName.<init>(SNIHostName.java:99)
at sun.security.util.HostnameChecker.matchDNS(HostnameChecker.java:193)
... 113 more
问题分析
1、错误原因是什么
我们先从堆栈的最底层看起,先看最终出异常的地方java.net.IDN.toASCIIInternal():
for (int i = 0; i < dest.length(); i++) {
int c = dest.charAt(i);
if (isNonLDHAsciiCodePoint(c)) {
throw new IllegalArgumentException(
"Contains non-LDH ASCII characters");
}
}
//
// LDH stands for "letter/digit/hyphen", with characters restricted to the
// 26-letter Latin alphabet <A-Z a-z>, the digits <0-9>, and the hyphen
// <->.
// Non LDH refers to characters in the ASCII range, but which are not
// letters, digits or the hypen.
//
// non-LDH = 0..0x2C, 0x2E..0x2F, 0x3A..0x40, 0x5B..0x60, 0x7B..0x7F
//
private static boolean isNonLDHAsciiCodePoint(int ch){
return (0x0000 <= ch && ch <= 0x002C) ||
(0x002E <= ch && ch <= 0x002F) ||
(0x003A <= ch && ch <= 0x0040) ||
(0x005B <= ch && ch <= 0x0060) ||
(0x007B <= ch && ch <= 0x007F);
}
从isNonLDHAsciiCodePoint(int ch)方法的注释和实现上可以看到,我们域名xxx_xx.test.com.cn里的_(ASCII码:0x5F)是不符合这个校验规则的。
2、为什么设置了HttpsURLConnection的HostnameVerifier就能解决这个问题
我们从刚才的错误堆栈处往上追溯,到这一个堆栈这里:
at sun.security.ssl.X509TrustManagerImpl.checkTrusted(X509TrustManagerImpl.java:200)
这一步的下一步就开始调用checkIdentity来检查域名了,这一步的代码片段如下:
// check endpoint identity
String identityAlg = sslSocket.getSSLParameters().
getEndpointIdentificationAlgorithm();
if (identityAlg != null && identityAlg.length() != 0) {
checkIdentity(session, chain, identityAlg, checkClientTrusted);
}
可以看出这里是根据SSLParameters里的getEndpointIdentificationAlgorithm()返回的值来决定要不要做域名校验的。通过查找该方法内的属性值的set方法的调用方,发现在HttpsClient的afterConnect方法中根据条件设置了该属性的值(从错误堆栈上也可以看到有这个方法的堆栈记录),代码内关于这部分设置还写了很详细的注释,如下:
// We have two hostname verification approaches. One is in
// SSL/TLS socket layer, where the algorithm is configured with
// SSLParameters.setEndpointIdentificationAlgorithm(), and the
// hostname verification is done by X509ExtendedTrustManager when
// the algorithm is "HTTPS". The other one is in HTTPS layer,
// where the algorithm is customized by
// HttpsURLConnection.setHostnameVerifier(), and the hostname
// verification is done by HostnameVerifier when the default
// rules for hostname verification fail.
//
// The relationship between two hostname verification approaches
// likes the following:
//
// | EIA algorithm
// +----------------------------------------------
// | null | HTTPS | LDAP/other |
// -------------------------------------------------------------
// | |1 |2 |3 |
// HNV | default | Set HTTPS EIA | use EIA | HTTPS |
// |--------------------------------------------------------
// | non - |4 |5 |6 |
// | default | HTTPS/HNV | use EIA | HTTPS/HNV |
// -------------------------------------------------------------
//
// Abbreviation:
// EIA: the endpoint identification algorithm in SSL/TLS
// socket layer
// HNV: the hostname verification object in HTTPS layer
// Notes:
// case 1. default HNV and EIA is null
// Set EIA as HTTPS, hostname check done in SSL/TLS
// layer.
// case 2. default HNV and EIA is HTTPS
// Use existing EIA, hostname check done in SSL/TLS
// layer.
// case 3. default HNV and EIA is other than HTTPS
// Use existing EIA, EIA check done in SSL/TLS
// layer, then do HTTPS check in HTTPS layer.
// case 4. non-default HNV and EIA is null
// No EIA, no EIA check done in SSL/TLS layer, then do
// HTTPS check in HTTPS layer using HNV as override.
// case 5. non-default HNV and EIA is HTTPS
// Use existing EIA, hostname check done in SSL/TLS
// layer. No HNV override possible. We will review this
// decision and may update the architecture for JDK 7.
// case 6. non-default HNV and EIA is other than HTTPS
// Use existing EIA, EIA check done in SSL/TLS layer,
// then do HTTPS check in HTTPS layer as override.
boolean needToCheckSpoofing = true;
String identification =
s.getSSLParameters().getEndpointIdentificationAlgorithm();
if (identification != null && identification.length() != 0) {
if (identification.equalsIgnoreCase("HTTPS")) {
// Do not check server identity again out of SSLSocket,
// the endpoint will be identified during TLS handshaking
// in SSLSocket.
needToCheckSpoofing = false;
} // else, we don't understand the identification algorithm,
// need to check URL spoofing here.
} else {
boolean isDefaultHostnameVerifier = false;
// We prefer to let the SSLSocket do the spoof checks, but if
// the application has specified a HostnameVerifier (HNV),
// we will always use that.
if (hv != null) {
String canonicalName = hv.getClass().getCanonicalName();
if (canonicalName != null &&
canonicalName.equalsIgnoreCase(defaultHVCanonicalName)) {
isDefaultHostnameVerifier = true;
}
} else {
// Unlikely to happen! As the behavior is the same as the
// default hostname verifier, so we prefer to let the
// SSLSocket do the spoof checks.
isDefaultHostnameVerifier = true;
}
if (isDefaultHostnameVerifier) {
// If the HNV is the default from HttpsURLConnection, we
// will do the spoof checks in SSLSocket.
SSLParameters paramaters = s.getSSLParameters();
paramaters.setEndpointIdentificationAlgorithm("HTTPS");
s.setSSLParameters(paramaters);
needToCheckSpoofing = false;
}
}
我把这部分代码的逻辑以流程图的形式表示,看起来可能会更清晰一点,重点过程以红色字体表示:

从图上可以看出,当用户为
HttpsURLConnection设置了非默认的自定义hostnameVerifier,那么当SSL域名校验失败时,才会调用用户自定义的hostnameVerifier执行二次校验,当且仅当自定义的hostnameVerifier返回true时,才会认为域名校验成功。这也是为什么自定义HttpsURLConnection的hostnameVerifier为什么可以解决域名校验失败的原因。
解决方案
最简单的解决方案:自定义javax.net.ssl.HostnameVerifier实现,check方法直接返回true。
public class AcceptAllDomainHostnameVerifier implements HostnameVerifier {
public boolean verify(String hostname, SSLSession session){
return true;
}
}
但是这种接受所有SSL校验失败的域名会有安全风险,相对安全点的做法建立一个SSL校验失败的域名白名单列表,只有配置在该列表种的域名,才算通过二次校验。
public class AcceptAllDomainHostnameVerifier implements HostnameVerifier {
public boolean verify(String hostname, SSLSession session){
if (isInWhiteList(hostname)) {
return true;
}
return false;
}
}