全部产品
存储与CDN 数据库 安全 应用服务 数加·人工智能 数加·大数据基础服务 互联网中间件 视频服务 开发者工具 解决方案 物联网 钉钉智能硬件
HTTPDNS

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

更新时间:2017-11-14 09:10:31

1. 背景说明

本文主要介绍HTTPS(含SNI)业务场景下在Android端和iOS端实现“IP直连”的通用解决方案。如果您是Android开发者,并且以OkHttp作为网络开发框架,由于OkHttp提供了自定义DNS服务接口可以优雅地实现IP直连。其方案相比通用方案更加简单且通用性更强,推荐您参考HttpDns+OkHttp最佳实践接入HttpDns。

1.1 HTTPS

发送HTTPS请求首先要进行SSL/TLS握手,握手过程大致如下:

  1. 客户端发起握手请求,携带随机数、支持算法列表等参数。
  2. 服务端收到请求,选择合适的算法,下发公钥证书和随机数。
  3. 客户端对服务端证书进行校验,并发送随机数信息,该信息使用公钥加密。
  4. 服务端通过私钥获取随机数信息。
  5. 双方根据以上交互的信息生成session ticket,用作该连接后续数据传输的加密密钥。

上述过程中,和HTTPDNS有关的是第3步,客户端需要验证服务端下发的证书,验证过程有以下两个要点:

  1. 客户端用本地保存的根证书解开证书链,确认服务端下发的证书是由可信任的机构颁发的。
  2. 客户端需要检查证书的domain域和扩展域,看是否包含本次请求的host。

如果上述两点都校验通过,就证明当前的服务端是可信任的,否则就是不可信任,应当中断当前连接。

当客户端使用HTTPDNS解析域名时,请求URL中的host会被替换成HTTPDNS解析出来的IP,所以在证书验证的第2步,会出现domain不匹配的情况,导致SSL/TLS握手不成功。

1.2 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指定具体的域名证书进行通信。

2. HTTPS场景(非SNI)解决方案

针对“domain不匹配”问题,可以采用如下方案解决:hook证书校验过程中第2步,将IP直接替换成原来的域名,再执行证书验证。

【注意】基于该方案发起网络请求,若报出SSL校验错误,比如iOS系统报错kCFStreamErrorDomainSSL, -9813; The certificate for this server is invalid,Android系统报错System.err: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.,请检查应用场景是否为SNI(单IP多HTTPS域名)。

下面分别列出Android和iOS平台的示例代码。

2.1 Android示例

此示例针对HttpURLConnection接口。

  1. try {
  2. String url = "https://140.205.160.59/?sprefer=sypc00";
  3. HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection();
  4. connection.setRequestProperty("Host", "m.taobao.com");
  5. connection.setHostnameVerifier(new HostnameVerifier() {
  6. /*
  7. * 关于这个接口的说明,官方有文档描述:
  8. * This is an extended verification option that implementers can provide.
  9. * It is to be used during a handshake if the URL's hostname does not match the
  10. * peer's identification hostname.
  11. *
  12. * 使用HTTPDNS后URL里设置的hostname不是远程的主机名(如:m.taobao.com),与证书颁发的域不匹配,
  13. * Android HttpsURLConnection提供了回调接口让用户来处理这种定制化场景。
  14. * 在确认HTTPDNS返回的源站IP与Session携带的IP信息一致后,您可以在回调方法中将待验证域名替换为原来的真实域名进行验证。
  15. *
  16. */
  17. @Override
  18. public boolean verify(String hostname, SSLSession session) {
  19. return HttpsURLConnection.getDefaultHostnameVerifier().verify("m.taobao.com", session);
  20. return false;
  21. }
  22. });
  23. connection.connect();
  24. } catch (Exception e) {
  25. e.printStackTrace();
  26. } finally {
  27. }

2.2 iOS示例

此示例针对NSURLSession/NSURLConnection接口。

  1. - (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust
  2. forDomain:(NSString *)domain
  3. {
  4. /*
  5. * 创建证书校验策略
  6. */
  7. NSMutableArray *policies = [NSMutableArray array];
  8. if (domain) {
  9. [policies addObject:(__bridge_transfer id)SecPolicyCreateSSL(true, (__bridge CFStringRef)domain)];
  10. } else {
  11. [policies addObject:(__bridge_transfer id)SecPolicyCreateBasicX509()];
  12. }
  13. /*
  14. * 绑定校验策略到服务端的证书上
  15. */
  16. SecTrustSetPolicies(serverTrust, (__bridge CFArrayRef)policies);
  17. /*
  18. * 评估当前serverTrust是否可信任,
  19. * 官方建议在result = kSecTrustResultUnspecified 或 kSecTrustResultProceed
  20. * 的情况下serverTrust可以被验证通过,https://developer.apple.com/library/ios/technotes/tn2232/_index.html
  21. * 关于SecTrustResultType的详细信息请参考SecTrust.h
  22. */
  23. SecTrustResultType result;
  24. SecTrustEvaluate(serverTrust, &result);
  25. return (result == kSecTrustResultUnspecified || result == kSecTrustResultProceed);
  26. }
  27. /*
  28. * NSURLConnection
  29. */
  30. - (void)connection:(NSURLConnection *)connection
  31. willSendRequestForAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge
  32. {
  33. if (!challenge) {
  34. return;
  35. }
  36. /*
  37. * URL里面的host在使用HTTPDNS的情况下被设置成了IP,此处从HTTP Header中获取真实域名
  38. */
  39. NSString* host = [[self.request allHTTPHeaderFields] objectForKey:@"host"];
  40. if (!host) {
  41. host = self.request.URL.host;
  42. }
  43. /*
  44. * 判断challenge的身份验证方法是否是NSURLAuthenticationMethodServerTrust(HTTPS模式下会进行该身份验证流程),
  45. * 在没有配置身份验证方法的情况下进行默认的网络请求流程。
  46. */
  47. if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust])
  48. {
  49. if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {
  50. /*
  51. * 验证完以后,需要构造一个NSURLCredential发送给发起方
  52. */
  53. NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
  54. [[challenge sender] useCredential:credential forAuthenticationChallenge:challenge];
  55. } else {
  56. /*
  57. * 验证失败,进入默认处理流程
  58. */
  59. [[challenge sender] continueWithoutCredentialForAuthenticationChallenge:challenge];
  60. }
  61. } else {
  62. /*
  63. * 对于其他验证方法直接进行处理流程
  64. */
  65. [[challenge sender] continueWithoutCredentialForAuthenticationChallenge:challenge];
  66. }
  67. }
  68. ////////////////////////////////////////////////////////////
  69. ////////////////////////////////////////////////////////////
  70. ////////////////////////////////////////////////////////////
  71. ////////////////////////////////////////////////////////////
  72. /*
  73. * NSURLSession
  74. */
  75. - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
  76. didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
  77. completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * __nullable credential))completionHandler
  78. {
  79. if (!challenge) {
  80. return;
  81. }
  82. NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
  83. NSURLCredential *credential = nil;
  84. /*
  85. * 获取原始域名信息。
  86. */
  87. NSString* host = [[self.request allHTTPHeaderFields] objectForKey:@"host"];
  88. if (!host) {
  89. host = self.request.URL.host;
  90. }
  91. if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
  92. if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {
  93. disposition = NSURLSessionAuthChallengeUseCredential;
  94. credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
  95. } else {
  96. disposition = NSURLSessionAuthChallengePerformDefaultHandling;
  97. }
  98. } else {
  99. disposition = NSURLSessionAuthChallengePerformDefaultHandling;
  100. }
  101. // 对于其他的challenges直接使用默认的验证方案
  102. completionHandler(disposition,credential);
  103. }

3. HTTPS(SNI)场景方案

3.1 Android SNI场景

HTTPDNS Android Demo中针对HttpsURLConnection接口,提供了在SNI业务场景下使用HTTPDNS的示例代码。

定制SSLSocketFactory,在createSocket时替换为HTTPDNS的IP,并进行SNI/HostNameVerify配置。

  1. class TlsSniSocketFactory extends SSLSocketFactory {
  2. private final String TAG = TlsSniSocketFactory.class.getSimpleName();
  3. HostnameVerifier hostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier();
  4. private HttpsURLConnection conn;
  5. public TlsSniSocketFactory(HttpsURLConnection conn) {
  6. this.conn = conn;
  7. }
  8. @Override
  9. public Socket createSocket() throws IOException {
  10. return null;
  11. }
  12. @Override
  13. public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
  14. return null;
  15. }
  16. @Override
  17. public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException {
  18. return null;
  19. }
  20. @Override
  21. public Socket createSocket(InetAddress host, int port) throws IOException {
  22. return null;
  23. }
  24. @Override
  25. public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
  26. return null;
  27. }
  28. // TLS layer
  29. @Override
  30. public String[] getDefaultCipherSuites() {
  31. return new String[0];
  32. }
  33. @Override
  34. public String[] getSupportedCipherSuites() {
  35. return new String[0];
  36. }
  37. @Override
  38. public Socket createSocket(Socket plainSocket, String host, int port, boolean autoClose) throws IOException {
  39. String peerHost = this.conn.getRequestProperty("Host");
  40. if (peerHost == null)
  41. peerHost = host;
  42. Log.i(TAG, "customized createSocket. host: " + peerHost);
  43. InetAddress address = plainSocket.getInetAddress();
  44. if (autoClose) {
  45. // we don't need the plainSocket
  46. plainSocket.close();
  47. }
  48. // create and connect SSL socket, but don't do hostname/certificate verification yet
  49. SSLCertificateSocketFactory sslSocketFactory = (SSLCertificateSocketFactory) SSLCertificateSocketFactory.getDefault(0);
  50. SSLSocket ssl = (SSLSocket) sslSocketFactory.createSocket(address, port);
  51. // enable TLSv1.1/1.2 if available
  52. ssl.setEnabledProtocols(ssl.getSupportedProtocols());
  53. // set up SNI before the handshake
  54. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
  55. Log.i(TAG, "Setting SNI hostname");
  56. sslSocketFactory.setHostname(ssl, peerHost);
  57. } else {
  58. Log.d(TAG, "No documented SNI support on Android <4.2, trying with reflection");
  59. try {
  60. java.lang.reflect.Method setHostnameMethod = ssl.getClass().getMethod("setHostname", String.class);
  61. setHostnameMethod.invoke(ssl, peerHost);
  62. } catch (Exception e) {
  63. Log.w(TAG, "SNI not useable", e);
  64. }
  65. }
  66. // verify hostname and certificate
  67. SSLSession session = ssl.getSession();
  68. if (!hostnameVerifier.verify(peerHost, session))
  69. throw new SSLPeerUnverifiedException("Cannot verify hostname: " + peerHost);
  70. Log.i(TAG, "Established " + session.getProtocol() + " connection with " + session.getPeerHost() +
  71. " using " + session.getCipherSuite());
  72. return ssl;
  73. }
  74. }

对于需要设置SNI的站点,通常需要重定向请求,示例中也给出了重定向请求的处理方法。

  1. public void recursiveRequest(String path, String reffer) {
  2. URL url = null;
  3. try {
  4. url = new URL(path);
  5. conn = (HttpsURLConnection) url.openConnection();
  6. String ip = httpdns.getIpByHostAsync(url.getHost());
  7. if (ip != null) {
  8. // 通过HTTPDNS获取IP成功,进行URL替换和HOST头设置
  9. Log.d(TAG, "Get IP: " + ip + " for host: " + url.getHost() + " from HTTPDNS successfully!");
  10. String newUrl = path.replaceFirst(url.getHost(), ip);
  11. conn = (HttpsURLConnection) new URL(newUrl).openConnection();
  12. // 设置HTTP请求头Host域
  13. conn.setRequestProperty("Host", url.getHost());
  14. }
  15. conn.setConnectTimeout(30000);
  16. conn.setReadTimeout(30000);
  17. conn.setInstanceFollowRedirects(false);
  18. TlsSniSocketFactory sslSocketFactory = new TlsSniSocketFactory(conn);
  19. conn.setSSLSocketFactory(sslSocketFactory);
  20. conn.setHostnameVerifier(new HostnameVerifier() {
  21. /*
  22. * 关于这个接口的说明,官方有文档描述:
  23. * This is an extended verification option that implementers can provide.
  24. * It is to be used during a handshake if the URL's hostname does not match the
  25. * peer's identification hostname.
  26. *
  27. * 使用HTTPDNS后URL里设置的hostname不是远程的主机名(如:m.taobao.com),与证书颁发的域不匹配,
  28. * Android HttpsURLConnection提供了回调接口让用户来处理这种定制化场景。
  29. * 在确认HTTPDNS返回的源站IP与Session携带的IP信息一致后,您可以在回调方法中将待验证域名替换为原来的真实域名进行验证。
  30. *
  31. */
  32. @Override
  33. public boolean verify(String hostname, SSLSession session) {
  34. String host = conn.getRequestProperty("Host");
  35. if (null == host) {
  36. host = conn.getURL().getHost();
  37. }
  38. return HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session);
  39. }
  40. });
  41. int code = conn.getResponseCode();// Network block
  42. if (needRedirect(code)) {
  43. //临时重定向和永久重定向location的大小写有区分
  44. String location = conn.getHeaderField("Location");
  45. if (location == null) {
  46. location = conn.getHeaderField("location");
  47. }
  48. if (!(location.startsWith("http://") || location
  49. .startsWith("https://"))) {
  50. //某些时候会省略host,只返回后面的path,所以需要补全url
  51. URL originalUrl = new URL(path);
  52. location = originalUrl.getProtocol() + "://"
  53. + originalUrl.getHost() + location;
  54. }
  55. recursiveRequest(location, path);
  56. } else {
  57. // redirect finish.
  58. DataInputStream dis = new DataInputStream(conn.getInputStream());
  59. int len;
  60. byte[] buff = new byte[4096];
  61. StringBuilder response = new StringBuilder();
  62. while ((len = dis.read(buff)) != -1) {
  63. response.append(new String(buff, 0, len));
  64. }
  65. Log.d(TAG, "Response: " + response.toString());
  66. }
  67. } catch (MalformedURLException e) {
  68. Log.w(TAG, "recursiveRequest MalformedURLException");
  69. } catch (IOException e) {
  70. Log.w(TAG, "recursiveRequest IOException");
  71. } catch (Exception e) {
  72. Log.w(TAG, "unknow exception");
  73. } finally {
  74. if (conn != null) {
  75. conn.disconnect();
  76. }
  77. }
  78. }
  79. private boolean needRedirect(int code) {
  80. return code >= 300 && code < 400;
  81. }

3.2 iOS SNI场景

SNI(单IP多HTTPS证书)场景下,iOS上层网络库NSURLConnection/NSURLSession没有提供接口进行SNI字段的配置,因此需要Socket层级的底层网络库例如CFNetwork,来实现IP直连网络请求适配方案。而基于CFNetwork的解决方案需要开发者考虑数据的收发、重定向、解码、缓存等问题(CFNetwork是非常底层的网络实现),希望开发者合理评估该场景的使用风险。

方案详情在这篇《HTTPS SNI 业务场景“IP直连”方案说明》 里进行了详细的讨论。

本文导读目录