2/26/2016 更新1: NSURLConnection 在 iOS 9 中已经被标记为 deprecated,如果你想在生产环境里使用,可以将 NSURLConnection 实现替换为 NSURLSession。具体的代码可以参考 CustomHTTPProtocol

2/26/2016 更新2: NSURLProtocol 可能会导致某些情况下的意外崩溃,这与 NSURLProtocol 本身实现没有关系,可能与系统内部有一定的冲突,具体原因还在调查中。

最近在参与一个 iOS 项目的时候,遇到了一点部署在 AWS 上服务器 IP 地址解析上的问题,表现是大量用户反馈出现了 -1003 错误,即 NSURLErrorCannotFindHost,而且这个错误在蜂窝数据下和 WiFi 环境下都有发生。

服务器的 DNS 解析是由 Amazon 的 Route 53 来做的,搜索了相关的关键词,也发现了遇到类似的 问题 的开发者。暂时保留对 Route 53 在国内可用性的怀疑,从客户端方面着手解决。

想解决这个问题,方法很简单,让原来通过域名访问服务器的方式,改成使用 IP 地址访问。

在项目中使用七牛的 Objective-C SDK 时,看到其依赖了一个有趣的库叫 HappyDNS。其原理为一个 DNS Resolver,想必七牛的 SDK 也遭遇到与我类似的问题才诞生这么一个库。

那么,我们可以在程序中内置一个 DNS Resolver,剩下的事情就是让 App 使用 IP 地址访问我们的服务器了。在 HappyDNS 的 Issues 列表里,我们发现了一些非常有用的 讨论,关于利用 HappyDNS 来解决 DNS 解析不正确 / 劫持的方法,作者推荐使用 NSURLProtocol 来完成 NSURLRequest 的域名到 IP 的转换过程。

如果你对 NSURLProtocol 还不熟悉(这当然很正常!这是一个非常冷门的 Abstract Class!),可以通过 Ray Wenderlich 的 NSURLProtocol Tutorial 教程对这个类能干什么和它的基本结构有一个大致的了解。

在教程的 Demo 里,作者给我们演示了如何截获和更改我们的网络请求对象。阅读到这里,满怀欣喜的尝试了一下,果然看上去没有问题。原以为解决了问题,但是,如果是那么简单,也没有必要出现这篇文章了。

如果你的服务器仅仅通过 HTTP 协议访问,那么接下来的内容可以直接略过了,遵循教程里的方法,外加使用

[newRequest setValue:originRequest.URL.host forHTTPHeaderField:@"Host"];

将 HTTP 头部的 Host 字段设置为原来的地址,即可完美解决问题。

但是,

如果你的服务器 API 是通过 HTTPS 访问的话,那么更改 URL 会直接导致 SSL 验证不通过。

我们都知道,在 HTTPS 请求过程 中,会有一个协商 Session 密钥的过程。

图片来自 图解SSL/TLS协议

在这个过程中,假设我们访问的 URL 是 https://www.google.com/api ,同时 Google 服务器发送的证书是 *.google.com 的这么一个 Hostname 的话,验证通过。但我们在 NSURLProtocol 子类里更改了请求地址,变成了一个这样的地址 https://216.58.221.36/api ,但是这时访问 Google 服务器的 443 端口时,服务器仍返回的是 *.google.com。此时验证就会不通过,你可以利用浏览器测试一下,就会发现提示证书的 Host name mismatch。

Google SSL Mismatch

对于这样的问题,Apple 官方在 Technical Note TN2232 里并不推荐你采用忽略错误(Disabling server trust evaluation)这一做法,这样会极大的降低 HTTPS 的安全性。其推荐在 NSURLProtocol 的 delegate 的函数中使用 Security framework 的函数添加正确的 SSL 地址名来解决。

参考了许多资料之后,总结出大致的思路就是:

  1. 重写 NSURLProtocol 子类中 NSURLConnectionDelegate 的 - (void)connection:(NSURLConnection *)connection willSendRequestForAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge 方法
  2. 获取上下文中的 Server Trust 对象,对其有效性进行验证,如果合法则创建对应的 Crediental Object 并继续连接
  3. 如果 Server Trust 不合法,但是是处于可以恢复的状态(kSecTrustResultRecoverableTrustFailure),在调用中使用 SecPolicyCreateSSL 创建一个新的 Policy 然后应用到 Trust 上,重新尝试步骤 2

思路明确之后,解决问题就变得比较容易了,但是还是实现的过程中出现了一些小小的问题,这里分享给大家。

  1. 调用 SecPolicyCreateSSL 函数后还是无法通过验证

根据 Stack Overflow 上 Overriding TLS server validation hostname doesn’t seem to be working 这个回答,这里函数的 hostname 应该是客户端用于验证的叶证书名称,在用 IP 地址替换域名的请求里,这里原来的 hostname 应该是 IP 地址,使用被替换的域名作为参数即可。

  1. kCFStreamErrorDomainSSL, -9802 错误

这个错误并不是因为 NSURLProtocol 的实现错误造成的,而是由于 iOS 的 App Transport Security 不支持你的服务器上的加密方法造成的,解决方法为:添加加密方法或者在 Info.plist 里启用 NSAllowsArbitraryLoads

相关代码如下:

- (void)connection:(NSURLConnection *)connection willSendRequestForAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge
{
    SecTrustRef trust = challenge.protectionSpace.serverTrust;
    SecTrustResultType result;
    NSURLCredential *cred;

    OSStatus status = SecTrustEvaluate(trust, &result);

    NSInteger retryCount = 1;
    NSInteger retainCount = 0;

    while (status == errSecSuccess && retryCount >= 0) {
        if (result == kSecTrustResultProceed || result == kSecTrustResultUnspecified) {
            cred = [NSURLCredential credentialForTrust:trust];
            retainCount > 0 ? CFRelease(trust) : nil;
            [challenge.sender useCredential:cred forAuthenticationChallenge:challenge];
            return;
        } else if (result == kSecTrustResultRecoverableTrustFailure) {
            retryCount--;

            CFIndex numCerts = SecTrustGetCertificateCount(trust);
            NSMutableArray *certs = [NSMutableArray arrayWithCapacity:numCerts];
            for (CFIndex idx = 0; idx < numCerts; ++idx) {
                SecCertificateRef cert = SecTrustGetCertificateAtIndex(trust, idx);
                [certs addObject:CFBridgingRelease(cert)];
            }

            SecPolicyRef policy = SecPolicyCreateSSL(true, (__bridge CFStringRef)originalHostname);
            OSStatus err = SecTrustCreateWithCertificates((__bridge CFTypeRef _Nonnull)(certs), policy, &trust);
            retainCount++;
            CFRelease(policy);

            [certs removeAllObjects];
            certs = nil;

            if (err != noErr) {
                NSLog(@"Error creating trust: %d", err);
                break;
            }
            status = SecTrustEvaluate(trust, &result);
        }
    }

    [challenge.sender cancelAuthenticationChallenge:challenge];
}

完整的 Demo Project 在 GitHub 上以供大家参考,同时包括了 HappyDNS 的使用。

如果你还有不清楚的地方,欢迎留言与我交流。

References

  • http://oncenote.com/2014/10/21/Security-1-HTTPS/
  • http://www.ruanyifeng.com/blog/2014/02/ssl_tls.html
  • https://developer.apple.com/library/ios/technotes/tn2232/_index.html#//apple_ref/doc/uid/DTS40012884-CH1-SECSERVERNAME
  • http://lists.apple.com/archives/cocoa-dev/2013/May/msg00354.html
  • https://github.com/AFNetworking/AFNetworking/issues/1157
  • http://stackoverflow.com/questions/23786259/overriding-tls-server-validation-hostname-doesnt-seem-to-be-working
  • http://stackoverflow.com/questions/30778579/kcfstreamerrordomainssl-9802-when-connecting-to-a-server-by-ip-address-through