本文主要介绍HTTPS(含SNI)业务场景下在Android端和iOS端实现“IP直连”的通用解决方案。如果您是Android开发者,并且以OkHttp作为网络开发框架,由于OkHttp提供了自定义DNS服务接口可以优雅地实现IP直连。其方案相比通用方案更加简单且通用性更强,推荐您参考HttpDns+OkHttp最佳实践接入HttpDns。
发送HTTPS请求首先要进行SSL/TLS握手,握手过程大致如下:
上述过程中,和HTTPDNS有关的是第3步,客户端需要验证服务端下发的证书,验证过程有以下两个要点:
如果上述两点都校验通过,就证明当前的服务端是可信任的,否则就是不可信任,应当中断当前连接。
当客户端使用HTTPDNS解析域名时,请求URL中的host会被替换成HTTPDNS解析出来的IP,所以在证书验证的第2步,会出现domain不匹配的情况,导致SSL/TLS握手不成功。
SNI(Server Name Indication)是为了解决一个服务器使用多个域名和证书的SSL/TLS扩展。它的工作原理如下:
目前,大多数操作系统和浏览器都已经很好地支持SNI扩展,OpenSSL 0.9.8也已经内置这一功能。
上述过程中,当客户端使用HTTPDNS解析域名时,请求URL中的host会被替换成HTTPDNS解析出来的IP,导致服务器获取到的域名为解析后的IP,无法找到匹配的证书,只能返回默认的证书或者不返回,所以会出现SSL/TLS握手不成功的错误。
比如当你需要通过HTTPS访问CDN资源时,CDN的站点往往服务了很多的域名,所以需要通过SNI指定具体的域名证书进行通信。
针对“domain不匹配”问题,可以采用如下方案解决:hook证书校验过程中第2步,将IP直接替换成原来的域名,再执行证书验证。
【注意】基于该方案发起网络请求,若报出SSL校验错误,比如iOS系统报错kCFStreamErrorDomainSSL, -9813; The certificate for this server is invalid,Android系统报错System.err: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.,请检查应用场景是否为SNI(单IP多HTTPS域名)。
下面分别列出Android和iOS平台的示例代码。
此示例针对HttpURLConnection接口。
try {String url = "https://140.205.160.59/?sprefer=sypc00";HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection();connection.setRequestProperty("Host", "m.taobao.com");connection.setHostnameVerifier(new HostnameVerifier() {/** 关于这个接口的说明,官方有文档描述:* This is an extended verification option that implementers can provide.* It is to be used during a handshake if the URL's hostname does not match the* peer's identification hostname.** 使用HTTPDNS后URL里设置的hostname不是远程的主机名(如:m.taobao.com),与证书颁发的域不匹配,* Android HttpsURLConnection提供了回调接口让用户来处理这种定制化场景。* 在确认HTTPDNS返回的源站IP与Session携带的IP信息一致后,您可以在回调方法中将待验证域名替换为原来的真实域名进行验证。**/@Overridepublic boolean verify(String hostname, SSLSession session) {return HttpsURLConnection.getDefaultHostnameVerifier().verify("m.taobao.com", session);return false;}});connection.connect();} catch (Exception e) {e.printStackTrace();} finally {}
此示例针对NSURLSession/NSURLConnection接口。
- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrustforDomain:(NSString *)domain{/** 创建证书校验策略*/NSMutableArray *policies = [NSMutableArray array];if (domain) {[policies addObject:(__bridge_transfer id)SecPolicyCreateSSL(true, (__bridge CFStringRef)domain)];} else {[policies addObject:(__bridge_transfer id)SecPolicyCreateBasicX509()];}/** 绑定校验策略到服务端的证书上*/SecTrustSetPolicies(serverTrust, (__bridge CFArrayRef)policies);/** 评估当前serverTrust是否可信任,* 官方建议在result = kSecTrustResultUnspecified 或 kSecTrustResultProceed* 的情况下serverTrust可以被验证通过,https://developer.apple.com/library/ios/technotes/tn2232/_index.html* 关于SecTrustResultType的详细信息请参考SecTrust.h*/SecTrustResultType result;SecTrustEvaluate(serverTrust, &result);return (result == kSecTrustResultUnspecified || result == kSecTrustResultProceed);}/** NSURLConnection*/- (void)connection:(NSURLConnection *)connectionwillSendRequestForAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge{if (!challenge) {return;}/** URL里面的host在使用HTTPDNS的情况下被设置成了IP,此处从HTTP Header中获取真实域名*/NSString* host = [[self.request allHTTPHeaderFields] objectForKey:@"host"];if (!host) {host = self.request.URL.host;}/** 判断challenge的身份验证方法是否是NSURLAuthenticationMethodServerTrust(HTTPS模式下会进行该身份验证流程),* 在没有配置身份验证方法的情况下进行默认的网络请求流程。*/if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]){if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {/** 验证完以后,需要构造一个NSURLCredential发送给发起方*/NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];[[challenge sender] useCredential:credential forAuthenticationChallenge:challenge];} else {/** 验证失败,进入默认处理流程*/[[challenge sender] continueWithoutCredentialForAuthenticationChallenge:challenge];}} else {/** 对于其他验证方法直接进行处理流程*/[[challenge sender] continueWithoutCredentialForAuthenticationChallenge:challenge];}}/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////** NSURLSession*/- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)taskdidReceiveChallenge:(NSURLAuthenticationChallenge *)challengecompletionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * __nullable credential))completionHandler{if (!challenge) {return;}NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;NSURLCredential *credential = nil;/** 获取原始域名信息。*/NSString* host = [[self.request allHTTPHeaderFields] objectForKey:@"host"];if (!host) {host = self.request.URL.host;}if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {disposition = NSURLSessionAuthChallengeUseCredential;credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];} else {disposition = NSURLSessionAuthChallengePerformDefaultHandling;}} else {disposition = NSURLSessionAuthChallengePerformDefaultHandling;}// 对于其他的challenges直接使用默认的验证方案completionHandler(disposition,credential);}
在HTTPDNS Android Demo中针对HttpsURLConnection接口,提供了在SNI业务场景下使用HTTPDNS的示例代码。
定制SSLSocketFactory,在createSocket时替换为HTTPDNS的IP,并进行SNI/HostNameVerify配置。
class TlsSniSocketFactory extends SSLSocketFactory {private final String TAG = TlsSniSocketFactory.class.getSimpleName();HostnameVerifier hostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier();private HttpsURLConnection conn;public TlsSniSocketFactory(HttpsURLConnection conn) {this.conn = conn;}@Overridepublic Socket createSocket() throws IOException {return null;}@Overridepublic Socket createSocket(String host, int port) throws IOException, UnknownHostException {return null;}@Overridepublic Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException {return null;}@Overridepublic Socket createSocket(InetAddress host, int port) throws IOException {return null;}@Overridepublic Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {return null;}// TLS layer@Overridepublic String[] getDefaultCipherSuites() {return new String[0];}@Overridepublic String[] getSupportedCipherSuites() {return new String[0];}@Overridepublic Socket createSocket(Socket plainSocket, String host, int port, boolean autoClose) throws IOException {String peerHost = this.conn.getRequestProperty("Host");if (peerHost == null)peerHost = host;Log.i(TAG, "customized createSocket. host: " + peerHost);InetAddress address = plainSocket.getInetAddress();if (autoClose) {// we don't need the plainSocketplainSocket.close();}// create and connect SSL socket, but don't do hostname/certificate verification yetSSLCertificateSocketFactory sslSocketFactory = (SSLCertificateSocketFactory) SSLCertificateSocketFactory.getDefault(0);SSLSocket ssl = (SSLSocket) sslSocketFactory.createSocket(address, port);// enable TLSv1.1/1.2 if availablessl.setEnabledProtocols(ssl.getSupportedProtocols());// set up SNI before the handshakeif (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {Log.i(TAG, "Setting SNI hostname");sslSocketFactory.setHostname(ssl, peerHost);} else {Log.d(TAG, "No documented SNI support on Android <4.2, trying with reflection");try {java.lang.reflect.Method setHostnameMethod = ssl.getClass().getMethod("setHostname", String.class);setHostnameMethod.invoke(ssl, peerHost);} catch (Exception e) {Log.w(TAG, "SNI not useable", e);}}// verify hostname and certificateSSLSession session = ssl.getSession();if (!hostnameVerifier.verify(peerHost, session))throw new SSLPeerUnverifiedException("Cannot verify hostname: " + peerHost);Log.i(TAG, "Established " + session.getProtocol() + " connection with " + session.getPeerHost() +" using " + session.getCipherSuite());return ssl;}}
对于需要设置SNI的站点,通常需要重定向请求,示例中也给出了重定向请求的处理方法。
public void recursiveRequest(String path, String reffer) {URL url = null;try {url = new URL(path);conn = (HttpsURLConnection) url.openConnection();String ip = httpdns.getIpByHostAsync(url.getHost());if (ip != null) {// 通过HTTPDNS获取IP成功,进行URL替换和HOST头设置Log.d(TAG, "Get IP: " + ip + " for host: " + url.getHost() + " from HTTPDNS successfully!");String newUrl = path.replaceFirst(url.getHost(), ip);conn = (HttpsURLConnection) new URL(newUrl).openConnection();// 设置HTTP请求头Host域conn.setRequestProperty("Host", url.getHost());}conn.setConnectTimeout(30000);conn.setReadTimeout(30000);conn.setInstanceFollowRedirects(false);TlsSniSocketFactory sslSocketFactory = new TlsSniSocketFactory(conn);conn.setSSLSocketFactory(sslSocketFactory);conn.setHostnameVerifier(new HostnameVerifier() {/** 关于这个接口的说明,官方有文档描述:* This is an extended verification option that implementers can provide.* It is to be used during a handshake if the URL's hostname does not match the* peer's identification hostname.** 使用HTTPDNS后URL里设置的hostname不是远程的主机名(如:m.taobao.com),与证书颁发的域不匹配,* Android HttpsURLConnection提供了回调接口让用户来处理这种定制化场景。* 在确认HTTPDNS返回的源站IP与Session携带的IP信息一致后,您可以在回调方法中将待验证域名替换为原来的真实域名进行验证。**/@Overridepublic boolean verify(String hostname, SSLSession session) {String host = conn.getRequestProperty("Host");if (null == host) {host = conn.getURL().getHost();}return HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session);}});int code = conn.getResponseCode();// Network blockif (needRedirect(code)) {//临时重定向和永久重定向location的大小写有区分String location = conn.getHeaderField("Location");if (location == null) {location = conn.getHeaderField("location");}if (!(location.startsWith("http://") || location.startsWith("https://"))) {//某些时候会省略host,只返回后面的path,所以需要补全urlURL originalUrl = new URL(path);location = originalUrl.getProtocol() + "://"+ originalUrl.getHost() + location;}recursiveRequest(location, path);} else {// redirect finish.DataInputStream dis = new DataInputStream(conn.getInputStream());int len;byte[] buff = new byte[4096];StringBuilder response = new StringBuilder();while ((len = dis.read(buff)) != -1) {response.append(new String(buff, 0, len));}Log.d(TAG, "Response: " + response.toString());}} catch (MalformedURLException e) {Log.w(TAG, "recursiveRequest MalformedURLException");} catch (IOException e) {Log.w(TAG, "recursiveRequest IOException");} catch (Exception e) {Log.w(TAG, "unknow exception");} finally {if (conn != null) {conn.disconnect();}}}private boolean needRedirect(int code) {return code >= 300 && code < 400;}
SNI(单IP多HTTPS证书)场景下,iOS上层网络库NSURLConnection/NSURLSession没有提供接口进行SNI字段的配置,因此需要Socket层级的底层网络库例如CFNetwork,来实现IP直连网络请求适配方案。而基于CFNetwork的解决方案需要开发者考虑数据的收发、重定向、解码、缓存等问题(CFNetwork是非常底层的网络实现),希望开发者合理评估该场景的使用风险。
方案详情在这篇《HTTPS SNI 业务场景“IP直连”方案说明》 里进行了详细的讨论。
在文档使用中是否遇到以下问题
更多建议
匿名提交