解决「 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 转换为其对应的域名,然后再进行下一步操作。具体的代码如下:

- (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: 方法的定义如下,可以参考 AFNetworking 中 AFSecurityPolicy 模块的代码。

- (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 参见:https://github.com/kangzubin/DevDemo/tree/master/TestHTTPDNS

SNI 场景

通常情况下,一台服务器往往会配置多个域名来建立不同 Web 站点或提供不同的服务。例如,域名 a.com 和 b.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 直连”方案说明》
https://help.aliyun.com/knowledge_detail/60147.html。

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

总结

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

未经允许不得转载:技术啦 - 关注IT,建站和运维,分享最新教程,资源 » 解决「 HTTPDNS + HTTPS 」的证书校验问题

赞 (0) 打赏

评论 1

评论前必须登录!

登陆 注册
  1. 666.porn cuckoldVery well written information. It will be valuable to anybody who utilizes it, including me. Keep up the good work - for sure i will check out more posts.