解决「HTTPDNS + HTTPS」的证书校验问题

为了提升 App 网络请求的稳定可靠,从不同维度考虑有很多的优化方案,今天我们就从“域名解析”切入来讲一讲。

下面先来介绍一下 HTTPDNS 服务,以及接入 HTTPDNS 后对 App 中原有的 HTTPS 请求的证书校验带来的影响和相关解决方案。

HTTPDNS

我们知道,客户端向服务端发起一个请求时,在建立 TCP/IP 连接前,需要有一个步骤就是根据请求 URL 中的域名获取对应服务器的 IP 地址,即 DNS 解析

但在移动互联网络中,我们经常会遭遇到运营商的 DNS 劫持(利益使然),导致 Web 页面出现弹窗、小广告、服务不稳定、不可用等。为了解决这种情况,很多云服务厂商都提供了 HTTPDNS 服务:

HTTPDNS 使用 HTTP 协议进行域名解析,代替现有基于 UDP 的 DNS 协议,域名解析请求直接发送到云服务商的 HTTPDNS 服务器,从而绕过运营商的 Local DNS,能够有效避免 Local DNS 造成的域名劫持、调度不精准、解析延迟、失败率高、不稳定等问题。 —— 引自阿里云文档

HTTPDNS 的基本原理如下图所示:

问题

当客户端使用 HTTPDNS 解析域名时,请求 URL 中的 host 会被替换成 HTTPDNS 解析出来的 IP,这种方案对于 HTTP 请求不会有任何影响,但是对于 HTTPS 来说,由于请求前多了一个 SSL/TLS 握手过程,涉及到证书校验,这时候问题就来了!

SSL/TLS 握手过程中,服务端下发的证书里的 CN 字段(即证书颁发的域名)仍然为域名的形式,但是请求中的 host 在请求前已经被我们替换为 IP 了,这时在证书校验时,就会出现 domain 不匹配的情况,导致 SSL/TLS 握手不成功,请求会被取消掉(error code: -999)。

解决方法

因此,我们需要对证书校验的逻辑做一下小改动,在 NSURLSession 的证书校验代理方法(URLSession:didReceiveChallenge:completionHandler:)中,增加一个前置处理:把待验证的 domian 由原本的 IP 转换为其对应的域名,然后再进行下一步操作。具体的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
- (void)URLSession:(NSURLSession *)session
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler
{
NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
NSURLCredential *credential = nil;

// 证书验证前置处理
NSString *domain = challenge.protectionSpace.host; // 获取当前请求的 host(域名或者 IP),假设此时为:123.206.23.22
NSString *testHostIP = self.tempDNS[self.testHost];
// 此时服务端返回的证书里的 CN 字段(即证书颁发的域名)与上述 host 可能不一致,
// 因为上述 host 在发请求前已经被我们替换为 IP,所以校验证书时会发现域名不一致而无法通过,导致请求被取消掉,
// 所以,这里在校验证书前做一下替换处理。
if ([domain isEqualToString:testHostIP]) {
domain = self.testHost; // 替换为对应域名:kangzubin.com
}

// 以下逻辑与 AFNetworking -> AFURLSessionManager.m 里的代码一致
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:domain]) {
// 上述 `evaluateServerTrust:forDomain:` 方法用于验证 SSL 握手过程中服务端返回的证书是否可信任,
// 以及请求的 URL 中的域名与证书里声明的的 CN 字段是否一致。
credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
if (credential) {
disposition = NSURLSessionAuthChallengeUseCredential;
} else {
disposition = NSURLSessionAuthChallengePerformDefaultHandling;
}
} else {
disposition = NSURLSessionAuthChallengeCancelAuthenticationChallenge;
}
} else {
disposition = NSURLSessionAuthChallengePerformDefaultHandling;
}

if (completionHandler) {
completionHandler(disposition, credential);
}
}

其中,evaluateServerTrust:forDomain: 方法的定义如下,可以参考 AFNetworkingAFSecurityPolicy 模块的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust forDomain:(NSString *)domain {
// 创建证书校验策略
NSMutableArray *policies = [NSMutableArray array];
if (domain) {
// 需要验证请求的域名与证书中声明的 CN 字段是否一致
[policies addObject:(__bridge_transfer id)SecPolicyCreateSSL(true, (__bridge CFStringRef)domain)];
} else {
[policies addObject:(__bridge_transfer id)SecPolicyCreateBasicX509()];
}

// 绑定校验策略到服务端返回的证书(serverTrust)上
SecTrustSetPolicies(serverTrust, (__bridge CFArrayRef)policies);

// 评估当前 serverTrust 是否可信任,
// 根据苹果文档:https://developer.apple.com/library/ios/technotes/tn2232/_index.html
// 当 result 为 kSecTrustResultUnspecified 或 kSecTrustResultProceed 的情况下,serverTrust 可以被验证通过。
SecTrustResultType result;
SecTrustEvaluate(serverTrust, &result);
return (result == kSecTrustResultUnspecified || result == kSecTrustResultProceed);
}

上述解决方法只适用于一台服务器的 IP 只配置了一个默认的域名和 SSL 证书的情况

详细的 Demo 参见:TestHTTPDNS

SNI 场景

通常情况下,一台服务器往往会配置多个域名来建立不同 Web 站点或提供不同的服务。例如,域名 a.comb.com 都同时解析到同一 IP 1.1.1.1 上,然后服务器根据客户端请求中的 Host 字段来区分,将请求分配给不同的后台服务来处理。

如前面所述,对于 HTTPS 请求前,需要额外进行 SSL/TLS 握手,但是由于服务器配置了多个域名的 SSL 证书,在握手发送证书时,不知道客户端访问的是哪个域名(因为握手是在某一具体请求之前进行的),所以无法根据不同域名发送不同的证书。

SNI(Server Name Indication) 就是为了解决一个服务器使用多个域名和证书的 SSL/TLS 扩展。它的工作原理是:在进行 SSL/TLS 握手之前,先发送要访问站点的域名(Hostname),这样服务器会根据这个域名返回一个合适的证书。目前,大多数操作系统和浏览器以及主流 HTTP 服务器软件都已经很好地支持 SNI 扩展。

但是同样的问题又来了,当我们采用 HTTPDNS 解析域名,如前所述,请求 URL 中的 host 会被替换成解析后的 IP,此时握手前发送的 SNI 字段是 IP,导致服务器最终获取到的”域名”仍然为 IP,无法找到匹配的证书,只能返回默认的证书或者不返回,所以也会出现 SSL/TLS 握手不成功的错误。

而对于这种场景,iOS 上层网络 API NSURLConnection/NSURLSession 都没有提供相关方法进行 SNI 字段的配置,因此需要 Socket 层级的底层网络库,例如 CFNetwork,来实现 IP 直连网络请求适配方案。详细的解决方案可以参考这篇文章:《HTTPS SNI 业务场景“IP 直连”方案说明》

注:以上关于 SNI 的部分文字参考了阿里云 HTTPDNS iOS SDK 的相关技术文档,在此特别感谢!

总结

本文简要介绍了 App 接入 HTTPDNS 服务后,对于两种不同的服务器配置场景 单 IP 单域名证书单 IP 多域名证书(SNI),如何解决 HTTPS 请求在 SSL/TLS 握手过程的证书校验问题,不足之处,请多多指正。

参考文档