當前最佳實務文檔只針對結合使用時,如何使用HTTPDNS解析出的IP,關於HTTPDNS本身的解析服務,請先查看iOS SDK 開發手冊。
背景說明
本文主要介紹HTTPS(含SNI)業務情境下在iOS端實現”IP直連“的解決方案。
HTTPS
發送HTTPS請求首先要進行SSL/TLS握手,握手過程大致如下:
用戶端發起握手請求,攜帶隨機數、支援演算法列表等參數。
服務端收到請求,選擇合適的演算法,下發密鑰憑證和隨機數。
用戶端對服務端認證進行校正,並發送隨機數資訊,該資訊使用公開金鑰加密。
服務端通過私密金鑰擷取隨機數資訊。
雙方根據以上互動的資訊產生session ticket,用作該串連後續資料轉送的加密金鑰。
上述過程中,和HTTPDNS有關的是第三步,用戶端需要驗證服務端下發的認證,驗證過程有以下兩個要點:
用戶端用本地儲存的根憑證解開憑證鏈結,確認服務端下發的認證是由可信任的機構頒發的。
用戶端需要檢查認證的domain域和擴充域,看是否包含本次請求的host。
如果上述兩點都校正通過,就證明當前的服務端是可信任的,否則就是不可信任,應當中斷當前串連。
當用戶端使用HTTPDNS解析網域名稱時,請求URL中的host會被替換成HTTPDNS解析出來的IP,所以在認證驗證的第2步,會出現domain不匹配的情況,導致SSL/TLS握手不成功。
SNI
SNI(Server Name Indication)是為瞭解決一個伺服器使用多個網域名稱和認證的SSL/TLS擴充。它的工作原理如下:
在串連到伺服器建立SSL連結之前先發送要訪問網站的網域名稱(Hostname)。
伺服器根據這個網域名稱返回一個合適的認證。
目前,大多數作業系統和瀏覽器都已經很好地支援SNI擴充,OpenSSL 0.9.8也已經內建這一功能。
上述過程中,當用戶端使用HTTPDNS解析網域名稱時,請求URL中的host會被替換成HTTPDNS解析出來的IP,導致伺服器擷取到的網域名稱為解析後的IP,無法找到匹配的認證,只能返回預設的認證或者不返回,所以會出現SSL/TLS握手不成功的錯誤。
比如當你需要通過HTTPS訪問CDN資源時,CDN的網站往往服務了很多的網域名稱,所以需要通過SNI指定具體的網域名稱認證進行通訊。
HTTPS情境(非SNI)解決方案
針對“domain不匹配”問題,可以採用如下方案解決:hook認證校正過程中第2步,將IP直接替換成原來的網域名稱,再執行認證驗證。
【注意】基於該方案發起網路請求,若報出SSL校正錯誤
,比如iOS系統報錯kCFStreamErrorDomainSSL, -9813; The certificate for this server is invalid
,請檢查應用情境是否為SNI(單IP多HTTPS網域名稱)。
此樣本針對NSURLSession/NSURLConnection介面。
- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust
forDomain:(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 *)connection
willSendRequestForAuthenticationChallenge:(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 *)task
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(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);
}
HTTPS(SNI)解決方案
SNI(單IP多HTTPS認證)情境下,iOS上層網路程式庫NSURLConnection/NSURLSession
沒有提供介面進行SNI欄位
的配置,因此需要Socket層級的底層網路程式庫例如CFNetwork
,來實現IP直連網路請求
適配方案。而基於CFNetwork的解決方案需要開發人員考慮資料的收發、重新導向、解碼、緩衝等問題(CFNetwork是非常底層的網路實現),希望開發人員合理評估該情境的使用風險。
針對SNI情境的方案,Socket層級的底層網路程式庫,大致有兩種:
基於CFNetWork,hook認證校正步驟。
基於原生支援設定SNI欄位的更底層的庫,比如libcurl。
下面將目前面臨的一些挑戰,以及應對策略介紹一下:
支援POST請求
使用NSURLProtocol攔截NSURLSession請求丟失body,故有以下幾種解決方案:
使用HTTPBodyStream擷取body,並賦值到body中,具體的代碼如下,可以解決上面提到的問題:
//
// NSURLRequest+NSURLProtocolExtension.h
//
//
// Created by ElonChan on 28/07/2017.
// Copyright © 2017 ChenYilong. All rights reserved.
//
#import <Foundation/Foundation.h>
@interface NSURLRequest (NSURLProtocolExtension)
- (NSURLRequest *)httpdns_getPostRequestIncludeBody;
@end
//
// NSURLRequest+NSURLProtocolExtension.h
//
//
// Created by ElonChan on 28/07/2017.
// Copyright © 2017 ChenYilong. All rights reserved.
//
#import "NSURLRequest+NSURLProtocolExtension.h"
@implementation NSURLRequest (NSURLProtocolExtension)
- (NSURLRequest *)httpdns_getPostRequestIncludeBody {
return [[self httpdns_getMutablePostRequestIncludeBody] copy];
}
- (NSMutableURLRequest *)httpdns_getMutablePostRequestIncludeBody {
NSMutableURLRequest * req = [self mutableCopy];
if ([self.HTTPMethod isEqualToString:@"POST"]) {
if (!self.HTTPBody) {
NSInteger maxLength = 1024;
uint8_t d[maxLength];
NSInputStream *stream = self.HTTPBodyStream;
NSMutableData *data = [[NSMutableData alloc] init];
[stream open];
BOOL endOfStreamReached = NO;
//不能用 [stream hasBytesAvailable]) 判斷,處理圖片檔案的時候這裡的[stream hasBytesAvailable]會始終返回YES,導致在while裡面死迴圈。
while (!endOfStreamReached) {
NSInteger bytesRead = [stream read:d maxLength:maxLength];
if (bytesRead == 0) { //檔案讀取到最後
endOfStreamReached = YES;
} else if (bytesRead == -1) { //檔案讀取錯誤
endOfStreamReached = YES;
} else if (stream.streamError == nil) {
[data appendBytes:(void *)d length:bytesRead];
}
}
req.HTTPBody = [data copy];
[stream close];
}
}
return req;
}
@end
使用方法:
在用於攔截請求的NSURLProtocol的子類中實現方法+canonicalRequestForRequest:
並處理request
對象:
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
return [request httpdns_getPostRequestIncludeBody];
}
下面介紹下相關方法的作用:
//NSURLProtocol.h
/*!
@method canInitWithRequest:
@abstract This method determines whether this protocol can handle
the given request.
@discussion A concrete subclass should inspect the given request and
determine whether or not the implementation can perform a load with
that request. This is an abstract method. Sublasses must provide an
implementation.
@param request A request to inspect.
@result YES if the protocol can handle the given request, NO if not.
*/
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
/*!
@method canonicalRequestForRequest:
@abstract This method returns a canonical version of the given
request.
@discussion It is up to each concrete protocol implementation to
define what "canonical" means. However, a protocol should
guarantee that the same input request always yields the same
canonical form. Special consideration should be given when
implementing this method since the canonical form of a request is
used to look up objects in the URL cache, a process which performs
equality checks between NSURLRequest objects.
<p>
This is an abstract method; sublasses must provide an
implementation.
@param request A request to make canonical.
@result The canonical form of the given request.
*/
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;
以下為上述方法的相關解釋:
//NSURLProtocol.h
/*!
* @method:建立NSURLProtocol執行個體,NSURLProtocol註冊之後,所有的NSURLConnection都會通過這個方法檢查是否持有該Http請求。
@parma :
@return: YES:持有該Http請求NO:不持有該Http請求
*/
+ (BOOL)canInitWithRequest:(NSURLRequest *)request
/*!
* @method: NSURLProtocol抽象類別必須要實現。通常情況下這裡有一個最低的標準:即輸入輸出請求滿足最基本的協議規範一致。因此這裡簡單的做法可以直接返回。一般情況下我們是不會去更改這個請求的。如果你想更改,比如給這個request添加一個title,組合成一個新的http請求。
@parma: 本地HttpRequest請求:request
@return:直接轉寄
*/
+ (NSURLRequest*)canonicalRequestForRequest:(NSURLRequest *)request
+[NSURLProtocol canInitWithRequest:]
負責篩選哪些網路請求需要被攔截。+[NSURLProtocol canonicalRequestForRequest:]
負責對需要攔截的網路請求NSURLRequest
進行重新構造。
這裡有一個注意點:+[NSURLProtocol canonicalRequestForRequest:]
的執行條件是 +[NSURLProtocol canInitWithRequest:]
傳回值為 YES
。
注意在攔截 NSURLSession
請求時,需要將用於攔截請求的 NSURLProtocol 的子類添加到 NSURLSessionConfiguration
中,用法如下:
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
NSArray *protocolArray = @[ [CUSTOMEURLProtocol class] ];
configuration.protocolClasses = protocolArray;
NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue mainQueue]];
換用其他提供了SNI欄位配置介面的更底層網路程式庫
如果使用第三方網路程式庫:curl中有一個-resolve
方法可以實現使用指定IP訪問HTTPS網站,iOS 中整合curl庫,參考curl文檔;
另外有一點也可以注意下,它也是支援IPv6環境的,只需要你在build時添加上--enable-ipv6
即可。
curl支援指定SNI欄位,設定SNI時我們需要構造的參數形如:{HTTPS網域名稱}:443:{IP地址}
假設你要訪問. www.example.org ,若IP為127.0.0.1 ,那麼通過這個方式來調用來設定SNI即可:
curl
*
—resolve ‘www.example.org:443:127.0.0.1’
iOS CURL庫
使用libcurl來解決,libcurl / cURL至少7.18.1(2008年3月30日)在SNI支援下編譯一個 SSL/TLS 工具包,curl
中有一個--resolve
方法可以實現使用指定IP訪問HTTPS網站。
在iOS實現中,代碼如下:
//{HTTPS網域名稱}:443:{IP地址}
NSString *curlHost = ...;
_hosts_list = curl_slist_append(_hosts_list, curlHost.UTF8String);
curl_easy_setopt(_curl, CURLOPT_RESOLVE, _hosts_list);
其中curlHost
形如:
{HTTPS網域名稱}:443:{IP地址}
_hosts_list
是結構體類型hosts_list
,可以設定多個IP與Host之間的映射關係。curl_easy_setopt
方法中傳入CURLOPT_RESOLVE
將該映射設定到 HTTPS 請求中。
這樣就可以達到設定SNI的目的。