全部產品
Search
文件中心

HTTPDNS:iOS端HTTPS(含SNI)業務情境:IP直連方案說明

更新時間:Jul 13, 2024

重要

當前最佳實務文檔只針對結合使用時,如何使用HTTPDNS解析出的IP,關於HTTPDNS本身的解析服務,請先查看iOS SDK 開發手冊

背景說明

本文主要介紹HTTPS(含SNI)業務情境下在iOS端實現”IP直連“的解決方案。

HTTPS

發送HTTPS請求首先要進行SSL/TLS握手,握手過程大致如下:

  1. 用戶端發起握手請求,攜帶隨機數、支援演算法列表等參數。

  2. 服務端收到請求,選擇合適的演算法,下發密鑰憑證和隨機數。

  3. 用戶端對服務端認證進行校正,並發送隨機數資訊,該資訊使用公開金鑰加密。

  4. 服務端通過私密金鑰擷取隨機數資訊。

  5. 雙方根據以上互動的資訊產生session ticket,用作該串連後續資料轉送的加密金鑰。

上述過程中,和HTTPDNS有關的是第三步,用戶端需要驗證服務端下發的認證,驗證過程有以下兩個要點:

  1. 用戶端用本地儲存的根憑證解開憑證鏈結,確認服務端下發的認證是由可信任的機構頒發的。

  2. 用戶端需要檢查認證的domain域和擴充域,看是否包含本次請求的host。

如果上述兩點都校正通過,就證明當前的服務端是可信任的,否則就是不可信任,應當中斷當前串連。

當用戶端使用HTTPDNS解析網域名稱時,請求URL中的host會被替換成HTTPDNS解析出來的IP,所以在認證驗證的第2步,會出現domain不匹配的情況,導致SSL/TLS握手不成功。

SNI

SNI(Server Name Indication)是為瞭解決一個伺服器使用多個網域名稱和認證的SSL/TLS擴充。它的工作原理如下:

  1. 在串連到伺服器建立SSL連結之前先發送要訪問網站的網域名稱(Hostname)。

  2. 伺服器根據這個網域名稱返回一個合適的認證。

目前,大多數作業系統和瀏覽器都已經很好地支援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的目的。

參考連結