iOS端HTTP场景,HTTPS(含SNI)业务场景“IP直连”方案说明

本文主要介绍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)的扩展,主要解决一台服务器能够提供多个域名服务的情况。

  • HTTP场景

    HTTP场景网络链路中不存在SSL/TLS握手,无需证书校验,直接将请求URL中的host替换成IP,在HTTP Header中设置Host为原始域名即可。

  • 普通HTTPS场景

    普通HTTPS场景使用“IP直连”对开发者来说很方便,直接将请求URL中的host替换成IP,在HTTP Header中设置Host为原始域名,在执行证书验证时将IP再替换成原来的域名即可。

  • SNI场景

    SNI(单IPHTTPS证书)场景下,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(单IPHTTPS域名)。

  • HTTPSSNI场景解决方案

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(2008330日)在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,可以设置多个IPHost之间的映射关系。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、重定向、缓存等问题