Browse Source

Ensure port isn't included in SNI hostname (#76)

Motivation:

gRPC derives the authority from various sources and uses this value for
the SNI server hostname. The authority should include non-standard ports
and the SNI hostname must not include the port. In some cases this
wasn't being respected and the port was being used in SNI this results
in handshake failures.

Modifications:

- Have the Connection sanitize the authority so that it's suitable for
SNI.

Result:

- Fewer handshake failures
- Resolves #71
George Barnett 11 months ago
parent
commit
b07aa654cf

+ 19 - 1
Sources/GRPCNIOTransportCore/Client/Connection/Connection.swift

@@ -89,6 +89,9 @@ package final class Connection: Sendable {
   /// being connected to.
   /// being connected to.
   private let authority: String?
   private let authority: String?
 
 
+  /// The name of the server used for the TLS SNI extension, if applicable.
+  private let sniServerHostname: String?
+
   /// The default compression algorithm used for requests.
   /// The default compression algorithm used for requests.
   private let defaultCompression: CompressionAlgorithm
   private let defaultCompression: CompressionAlgorithm
 
 
@@ -111,6 +114,20 @@ package final class Connection: Sendable {
     self.event.stream
     self.event.stream
   }
   }
 
 
+  private static func sanitizeAuthorityForSNI(_ authority: String) -> String {
+    // Strip off a trailing ":{PORT}". Look for the last non-digit byte, if it's
+    // a colon then keep everything up to that index.
+    let index = authority.utf8.lastIndex { byte in
+      return byte < UInt8(ascii: "0") || byte > UInt8(ascii: "9")
+    }
+
+    if let index = index, authority.utf8[index] == UInt8(ascii: ":") {
+      return String(authority.utf8[..<index])!
+    } else {
+      return authority
+    }
+  }
+
   package init(
   package init(
     address: SocketAddress,
     address: SocketAddress,
     authority: String?,
     authority: String?,
@@ -120,6 +137,7 @@ package final class Connection: Sendable {
   ) {
   ) {
     self.address = address
     self.address = address
     self.authority = authority
     self.authority = authority
+    self.sniServerHostname = authority.map { Self.sanitizeAuthorityForSNI($0) }
     self.defaultCompression = defaultCompression
     self.defaultCompression = defaultCompression
     self.enabledCompression = enabledCompression
     self.enabledCompression = enabledCompression
     self.http2Connector = http2Connector
     self.http2Connector = http2Connector
@@ -140,7 +158,7 @@ package final class Connection: Sendable {
           // The authority here is used for the SNI hostname in the TLS handshake (if applicable)
           // The authority here is used for the SNI hostname in the TLS handshake (if applicable)
           // where a raw IP address isn't permitted, so fallback to 'address.sniHostname' rather
           // where a raw IP address isn't permitted, so fallback to 'address.sniHostname' rather
           // than 'address.authority'.
           // than 'address.authority'.
-          authority: self.authority ?? self.address.sniHostname
+          sniServerHostname: self.sniServerHostname ?? self.address.sniHostname
         )
         )
       } catch let error as RPCError {
       } catch let error as RPCError {
         throw error
         throw error

+ 2 - 2
Sources/GRPCNIOTransportCore/Client/Connection/ConnectionFactory.swift

@@ -23,10 +23,10 @@ package protocol HTTP2Connector: Sendable {
   ///
   ///
   /// - Parameters:
   /// - Parameters:
   ///   - address: The address to connect to.
   ///   - address: The address to connect to.
-  ///   - authority: The authority as used for the TLS SNI extension (if applicable).
+  ///   - sniServerHostname: The name of the server used for the TLS SNI extension (if applicable).
   func establishConnection(
   func establishConnection(
     to address: SocketAddress,
     to address: SocketAddress,
-    authority: String?
+    sniServerHostname: String?
   ) async throws -> HTTP2Connection
   ) async throws -> HTTP2Connection
 }
 }
 
 

+ 2 - 2
Sources/GRPCNIOTransportHTTP2Posix/HTTP2ClientTransport+Posix.swift

@@ -165,7 +165,7 @@ extension HTTP2ClientTransport.Posix {
 
 
     func establishConnection(
     func establishConnection(
       to address: GRPCNIOTransportCore.SocketAddress,
       to address: GRPCNIOTransportCore.SocketAddress,
-      authority: String?
+      sniServerHostname: String?
     ) async throws -> HTTP2Connection {
     ) async throws -> HTTP2Connection {
       let (channel, multiplexer) = try await ClientBootstrap(
       let (channel, multiplexer) = try await ClientBootstrap(
         group: self.eventLoopGroup
         group: self.eventLoopGroup
@@ -175,7 +175,7 @@ extension HTTP2ClientTransport.Posix {
             try channel.pipeline.syncOperations.addHandler(
             try channel.pipeline.syncOperations.addHandler(
               NIOSSLClientHandler(
               NIOSSLClientHandler(
                 context: sslContext,
                 context: sslContext,
-                serverHostname: authority
+                serverHostname: sniServerHostname
               )
               )
             )
             )
           }
           }

+ 2 - 2
Sources/GRPCNIOTransportHTTP2TransportServices/HTTP2ClientTransport+TransportServices.swift

@@ -149,7 +149,7 @@ extension HTTP2ClientTransport.TransportServices {
 
 
     func establishConnection(
     func establishConnection(
       to address: GRPCNIOTransportCore.SocketAddress,
       to address: GRPCNIOTransportCore.SocketAddress,
-      authority: String?
+      sniServerHostname: String?
     ) async throws -> HTTP2Connection {
     ) async throws -> HTTP2Connection {
       let bootstrap: NIOTSConnectionBootstrap
       let bootstrap: NIOTSConnectionBootstrap
       let isPlainText: Bool
       let isPlainText: Bool
@@ -162,7 +162,7 @@ extension HTTP2ClientTransport.TransportServices {
       case .tls(let tlsConfig):
       case .tls(let tlsConfig):
         isPlainText = false
         isPlainText = false
         do {
         do {
-          let options = try NWProtocolTLS.Options(tlsConfig, authority: authority)
+          let options = try NWProtocolTLS.Options(tlsConfig, authority: sniServerHostname)
           bootstrap = NIOTSConnectionBootstrap(group: self.eventLoopGroup)
           bootstrap = NIOTSConnectionBootstrap(group: self.eventLoopGroup)
             .channelOption(NIOTSChannelOptions.waitForActivity, value: false)
             .channelOption(NIOTSChannelOptions.waitForActivity, value: false)
             .tlsOptions(options)
             .tlsOptions(options)

+ 61 - 0
Tests/GRPCNIOTransportCoreTests/Client/Connection/ConnectionTests.swift

@@ -21,6 +21,7 @@ import NIOCore
 import NIOHPACK
 import NIOHPACK
 import NIOHTTP2
 import NIOHTTP2
 import NIOPosix
 import NIOPosix
+import Synchronization
 import XCTest
 import XCTest
 
 
 final class ConnectionTests: XCTestCase {
 final class ConnectionTests: XCTestCase {
@@ -199,6 +200,44 @@ final class ConnectionTests: XCTestCase {
       XCTAssertEqual(error.code, .unavailable)
       XCTAssertEqual(error.code, .unavailable)
     }
     }
   }
   }
+
+  private func testAuthorityIsSanitized(authority: String, expected: String) async throws {
+    let recorder = SNIRecordingConnector()
+    let connection = Connection(
+      address: .ipv4(host: "ignored", port: 0),
+      authority: authority,
+      http2Connector: recorder,
+      defaultCompression: .none,
+      enabledCompression: .none
+    )
+
+    // The connect attempt will fail, but as a side effect the SNI hostname
+    // will be recorded.
+    await connection.run()
+    XCTAssertEqual(recorder.sniHostnames, [expected])
+  }
+
+  func testAuthorityIsSanitized() async throws {
+    try await self.testAuthorityIsSanitized(
+      authority: "foo.example.com",
+      expected: "foo.example.com"
+    )
+
+    try await self.testAuthorityIsSanitized(
+      authority: "foo.example.com:31415",
+      expected: "foo.example.com"
+    )
+
+    try await self.testAuthorityIsSanitized(
+      authority: "foo.example-31415",
+      expected: "foo.example-31415"
+    )
+
+    try await self.testAuthorityIsSanitized(
+      authority: "foo.example.com:abc123",
+      expected: "foo.example.com:abc123"
+    )
+  }
 }
 }
 
 
 extension ClientBootstrap {
 extension ClientBootstrap {
@@ -252,3 +291,25 @@ extension Metadata {
     self = metadata
     self = metadata
   }
   }
 }
 }
+
+final class SNIRecordingConnector: HTTP2Connector {
+  private let _sniHostnames: Mutex<[String?]>
+
+  var sniHostnames: [String?] {
+    self._sniHostnames.withLock { $0 }
+  }
+
+  init() {
+    self._sniHostnames = Mutex([])
+  }
+
+  func establishConnection(
+    to address: GRPCNIOTransportCore.SocketAddress,
+    sniServerHostname: String?
+  ) async throws -> GRPCNIOTransportCore.HTTP2Connection {
+    self._sniHostnames.withLock {
+      $0.append(sniServerHostname)
+    }
+    throw RPCError(code: .unavailable, message: "Test is expected to throw.")
+  }
+}

+ 3 - 3
Tests/GRPCNIOTransportCoreTests/Client/Connection/Utilities/HTTP2Connectors.swift

@@ -62,7 +62,7 @@ struct ThrowingConnector: HTTP2Connector {
 
 
   func establishConnection(
   func establishConnection(
     to address: GRPCNIOTransportCore.SocketAddress,
     to address: GRPCNIOTransportCore.SocketAddress,
-    authority: String?
+    sniServerHostname: String?
   ) async throws -> HTTP2Connection {
   ) async throws -> HTTP2Connection {
     throw self.error
     throw self.error
   }
   }
@@ -71,7 +71,7 @@ struct ThrowingConnector: HTTP2Connector {
 struct NeverConnector: HTTP2Connector {
 struct NeverConnector: HTTP2Connector {
   func establishConnection(
   func establishConnection(
     to address: GRPCNIOTransportCore.SocketAddress,
     to address: GRPCNIOTransportCore.SocketAddress,
-    authority: String?
+    sniServerHostname: String?
   ) async throws -> HTTP2Connection {
   ) async throws -> HTTP2Connection {
     fatalError("\(#function) called unexpectedly")
     fatalError("\(#function) called unexpectedly")
   }
   }
@@ -103,7 +103,7 @@ struct NIOPosixConnector: HTTP2Connector {
 
 
   func establishConnection(
   func establishConnection(
     to address: GRPCNIOTransportCore.SocketAddress,
     to address: GRPCNIOTransportCore.SocketAddress,
-    authority: String?
+    sniServerHostname: String?
   ) async throws -> HTTP2Connection {
   ) async throws -> HTTP2Connection {
     return try await ClientBootstrap(group: self.eventLoopGroup).connect(to: address) { channel in
     return try await ClientBootstrap(group: self.eventLoopGroup).connect(to: address) { channel in
       channel.eventLoop.makeCompletedFuture {
       channel.eventLoop.makeCompletedFuture {