本文主要介紹HTTP情境,HTTPS(含SNI)業務情境下在iOS端實現“IP直連”的解決方案。
概述
HTTP 是一種用於分布式、協作式和超媒體資訊系統的應用程式層協議,是全球資訊網資料通訊的基礎。它通過用戶端-伺服器模型工作,允許瀏覽器向 Web 服務器請求網頁內容,並將結果展示給使用者。HTTP 以明文形式傳輸資料,不提供加密機制,因此在傳輸過程中容易受到竊聽、篡改或中間人攻擊。
HTTPS是一種通過電腦網路進行安全通訊的傳輸協議,經由HTTP進行通訊,利用SSL/TLS建立全通道,加密資料包。HTTPS使用的主要目的是提供對網站伺服器的身份認證,同時保護交換資料的隱私與完整性,TLS是傳輸層加密協議,前身是SSL協議。HTTPS下有兩種業務情境普通情境和SNI情境。
SNI(Server Name Indication)用來改善伺服器與用戶端 SSL(Secure Socket Layer)和 TLS(Transport Layer Security)的擴充,主要解決一台伺服器能夠提供多個網域名稱服務 (DNS)的情況。
HTTP情境
HTTP情境網路鏈路中不存在SSL/TLS握手,無需認證校正,直接將請求URL中的host替換成IP,在HTTP Header中設定Host為原始網域名稱即可。
普通HTTPS情境
普通HTTPS情境使用“IP直連”對開發人員來說很方便,直接將請求URL中的host替換成IP,在HTTP Header中設定Host為原始網域名稱,在執行認證驗證時將IP再替換成原來的網域名稱即可。
SNI情境
SNI(單IP多HTTPS認證)情境下,iOS上層網路程式庫
NSURLConnection/NSURLSession沒有提供介面進行SNI欄位的配置,因此需要Socket層級的底層網路程式庫例如CFNetwork,來實現IP直連網路請求適配方案。而基於CFNetwork的解決方案需要開發人員考慮資料的收發、重新導向、解碼、緩衝等問題(CFNetwork是非常底層的網路實現),希望開發人員合理評估該情境的使用風險,我們推薦開發人員參考iOS14原生加密DNS方案解決SNI情境問題。
如果使用者在Server端使用了CDN服務,請參考SNI情境方案。
實踐方案
HTTP情境解決方案
1,直接將請求URL中的host替換成IP
2,在HTTP Header中設定Host為原始網域名稱即可
//構造請求
- (NSMutableURLRequest *)createRequest {
//假設網域名稱為example.com,從HTTPDNS解析出的ip為1.2.3.4
NSString *urlString = @"http://example.com/api";
//1,直接將請求URL中的host替換成IP
NSString *httpDnsString = [urlString stringByReplacingOccurrencesOfString:@"example.com" withString:@"1.2.3.4"];
NSURL *httpDnsURL = [NSURL URLWithString:httpDnsString];
NSMutableURLRequest *mutableReq = [NSMutableURLRequest requestWithURL:httpDnsURL];
//2,在HTTP Header中設定Host為原始網域名稱
[mutableReq setValue:@"example.com" forHTTPHeaderField:@"Host"];
return mutableReq;
}HTTPS下普通情境解決方案
1,直接將請求URL中的host替換成IP
2,在HTTP Header中設定Host為原始網域名稱
3,針對認證驗證時“domain不匹配”問題,可以採用如下方案解決:執行認證驗證時將IP直接替換成原來的網域名稱。
此樣本針對NSURLSession/NSURLConnection介面。
//構造請求
- (NSMutableURLRequest *)createRequest {
//假設網域名稱為example.com,從HTTPDNS解析出的ip為1.2.3.4
NSString *urlString = @"https://example.com/api";
//1,直接將請求URL中的host替換成IP
NSString *httpDnsString = [urlString stringByReplacingOccurrencesOfString:@"example.com" withString:@"1.2.3.4"];
NSURL *httpDnsURL = [NSURL URLWithString:httpDnsString];
NSMutableURLRequest *mutableReq = [NSMutableURLRequest requestWithURL:httpDnsURL];
//2,在HTTP Header中設定Host為原始網域名稱
[mutableReq setValue:@"example.com" forHTTPHeaderField:@"Host"];
return mutableReq;
}
//3認證驗證時將IP直接替換成原來的網域名稱
/*
* 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);
}
- (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);
}普通情境下AFNetworking網路程式庫下的IP直連,參考如下方案解決:
//使用該傳回值作為AFHTTPSessionManager請求的URL即可
+(NSString *)getIPStringFromAliCloudDNSResolverWithURLString: (NSString *)URLString manager: (AFHTTPSessionManager *)manager {
NSURL *originUrl = [NSURL URLWithString :URLString];
NSString *host = originUrl.host;
NSString *ip= [[DNSResolver share] getIpsByCacheWithDomain:host andExpiredIPEnabled:YES].firstObject;
NSString *ipURLString = URLString;
if (ip) {
//1,直接將請求URL中的host替換成IP
ipURLString = [URLString stringByReplacingOccurrencesOfString:host withString:ip];
//2,在HTTP Header中設定Host為原始網域名稱
[manager.requestSerializer setValue:originUrl.host forHTTPHeaderField:@"Host"];
__weak typeof (AFHTTPSessionManager *) weakSessionManager = manager;
//3認證驗證時將IP直接替換成原來的網域名稱
[manager setSessionDidReceiveAuthenticationChallengeBlock:^NSURLSessionAuthChallengeDisposition(NSURLSession*_Nonnullsession,
NSURLAuthenticationChallenge * _Nonnull challenge, NSURLCredential * _Nullable __autoreleasing * _Nullable credential) {
NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
//擷取原始網域名稱資訊
NSString *host = [[weakSessionManager.requestSerializer HTTPRequestHeaders] objectForKey:@"host" ];
if (!host) {
host = challenge.protectionSpace.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;
}
return disposition;
}];
}
return ipURLString;
}
+(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);
SecTrustResultType result;
SecTrustEvaluate(serverTrust, &result);
return (result == kSecTrustResultUnspecified || result == kSecTrustResultProceed);
}
基於該方案發起網路請求,若報出SSL校正錯誤,比如iOS系統報錯kCFStreamErrorDomainSSL, -9813; The certificate for this server is invalid,請檢查應用情境是否為SNI(單IP多HTTPS網域名稱)。
HTTPS下SNI情境解決方案
1. 自訂NSURLProtocol方案
SNI情境下的解決方案請參考Demo樣本工程源碼,下面將介紹目前面臨的一些挑戰,以及應對策略:
支援POST請求
使用NSURLProtocol攔截NSURLSession請求丟失body,解決方案如下:
使用HTTPBodyStream擷取body,並賦值到body中,具體的代碼如下,可以解決上面提到的問題:
//
// NSURLRequest+NSURLProtocolExtension.h
//
//
#import <Foundation/Foundation.h>
@interface NSURLRequest (NSURLProtocolExtension)
- (NSURLRequest *)alidns_getPostRequestIncludeBody;
@end
//
// NSURLRequest+NSURLProtocolExtension.h
//
//
#import "NSURLRequest+NSURLProtocolExtension.h"
@implementation NSURLRequest (NSURLProtocolExtension)
- (NSURLRequest *)alidns_getPostRequestIncludeBody {
return [[self alidns_getMutablePostRequestIncludeBody] copy];
}
- (NSMutableURLRequest *)alidns_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 alidns_getPostRequestIncludeBody];
}下面介紹相關方法的作用:
//NSURLProtocol.h
/*!
* @method: 建立NSURLProtocol執行個體,NSURLProtocol註冊之後,所有的NSURLConnection都會通過這個方法檢查是否持有該Http請求。
@param:
@return: YES:持有該Http請求 NO:不持有該Http請求
*/
+ (BOOL)canInitWithRequest:(NSURLRequest *)request
/*!
* @method: NSURLProtocol抽象類別必須要實現。通常情況下這裡有一個最低的標準:即輸入輸出請求滿足最基本的協議規範一致。因此這裡簡單的做法可以直接返回。一般情況下我們是不會去更改這個請求的。如果你想更改,比如給這個request添加一個title,組合成一個新的http請求。
@param: 本地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情境下AFNetworking網路程式庫下的IP直連,參考如下方案解決:
// 建立 NSURLSessionConfiguration 執行個體
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
// 設定使用自訂的 HTTP DNS 解析協議
config.protocolClasses = @[[CFHTTPDNSHTTPProtocol class]];
// 初始化 AFHTTPSessionManager
AFHTTPSessionManager *sessionManager = [[AFHTTPSessionManager alloc] initWithSessionConfiguration:config];
sessionManager.responseSerializer = [AFHTTPResponseSerializer serializer];
// 建立資料任務
NSURLSessionDataTask *task = [sessionManager dataTaskWithHTTPMethod:@"GET"
URLString:@"您的請求URL"
parameters:nil
headers:nil
uploadProgress:^(NSProgress *uploadProgress) {
NSLog(@"Upload progress: %@", uploadProgress.localizedDescription);
} downloadProgress:^(NSProgress *downloadProgress) {
NSLog(@"Download progress: %@", downloadProgress.localizedDescription);
} success:^(NSURLSessionDataTask *task, id responseObject) {
// 處理成功響應
NSLog(@"Success: %@", responseObject);
} failure:^(NSURLSessionDataTask *task, NSError *error) {
// 處理錯誤
NSLog(@"Failure: %@", error.localizedDescription);
}];
// 啟動任務
[task resume];2. 其他底層網路程式庫方案
以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的目的。
總結
方案 | 適用情境 | 優點 | 缺點 |
1, 僅替換URL 2,設定Host | HTTP情境 | 最簡單 | 僅適合HTTP明文傳輸協議 |
1, 替換URL 2,設定Host 3,認證驗證時將IP直接替換成原來的網域名稱 | HTTPS普通情境(非SNI情境) | 整合簡單,適用系統常見網路程式庫,NSURLSession, NSURLConnection, AFHTTPSessionManager | 不支援SNI |
自訂NSURLProtocol | 全部情境 | 完全基於系統底層API | 基於底層網路程式庫,開發維護成本高,需要自己處理收發、重新導向、解碼、緩衝等問題 |
libcurl | 全部情境 跨平台 | 社區成熟,文檔豐富, 可設定 SNI, 跨平台相容性強 | C語言介面對於iOS開發人員有一定的學習成本 需要自己處理Cookie、重新導向、緩衝等問題 |