Skip to content

Commit

Permalink
Ensure port isn't included in SNI hostname (#76)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
glbrntt authored Feb 26, 2025
1 parent acbd55d commit b07aa65
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 10 deletions.
20 changes: 19 additions & 1 deletion Sources/GRPCNIOTransportCore/Client/Connection/Connection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ package final class Connection: Sendable {
/// being connected to.
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.
private let defaultCompression: CompressionAlgorithm

Expand All @@ -111,6 +114,20 @@ package final class Connection: Sendable {
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(
address: SocketAddress,
authority: String?,
Expand All @@ -120,6 +137,7 @@ package final class Connection: Sendable {
) {
self.address = address
self.authority = authority
self.sniServerHostname = authority.map { Self.sanitizeAuthorityForSNI($0) }
self.defaultCompression = defaultCompression
self.enabledCompression = enabledCompression
self.http2Connector = http2Connector
Expand All @@ -140,7 +158,7 @@ package final class Connection: Sendable {
// 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
// than 'address.authority'.
authority: self.authority ?? self.address.sniHostname
sniServerHostname: self.sniServerHostname ?? self.address.sniHostname
)
} catch let error as RPCError {
throw error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ package protocol HTTP2Connector: Sendable {
///
/// - Parameters:
/// - 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(
to address: SocketAddress,
authority: String?
sniServerHostname: String?
) async throws -> HTTP2Connection
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ extension HTTP2ClientTransport.Posix {

func establishConnection(
to address: GRPCNIOTransportCore.SocketAddress,
authority: String?
sniServerHostname: String?
) async throws -> HTTP2Connection {
let (channel, multiplexer) = try await ClientBootstrap(
group: self.eventLoopGroup
Expand All @@ -175,7 +175,7 @@ extension HTTP2ClientTransport.Posix {
try channel.pipeline.syncOperations.addHandler(
NIOSSLClientHandler(
context: sslContext,
serverHostname: authority
serverHostname: sniServerHostname
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ extension HTTP2ClientTransport.TransportServices {

func establishConnection(
to address: GRPCNIOTransportCore.SocketAddress,
authority: String?
sniServerHostname: String?
) async throws -> HTTP2Connection {
let bootstrap: NIOTSConnectionBootstrap
let isPlainText: Bool
Expand All @@ -162,7 +162,7 @@ extension HTTP2ClientTransport.TransportServices {
case .tls(let tlsConfig):
isPlainText = false
do {
let options = try NWProtocolTLS.Options(tlsConfig, authority: authority)
let options = try NWProtocolTLS.Options(tlsConfig, authority: sniServerHostname)
bootstrap = NIOTSConnectionBootstrap(group: self.eventLoopGroup)
.channelOption(NIOTSChannelOptions.waitForActivity, value: false)
.tlsOptions(options)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import NIOCore
import NIOHPACK
import NIOHTTP2
import NIOPosix
import Synchronization
import XCTest

final class ConnectionTests: XCTestCase {
Expand Down Expand Up @@ -199,6 +200,44 @@ final class ConnectionTests: XCTestCase {
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 {
Expand Down Expand Up @@ -252,3 +291,25 @@ extension 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.")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ struct ThrowingConnector: HTTP2Connector {

func establishConnection(
to address: GRPCNIOTransportCore.SocketAddress,
authority: String?
sniServerHostname: String?
) async throws -> HTTP2Connection {
throw self.error
}
Expand All @@ -71,7 +71,7 @@ struct ThrowingConnector: HTTP2Connector {
struct NeverConnector: HTTP2Connector {
func establishConnection(
to address: GRPCNIOTransportCore.SocketAddress,
authority: String?
sniServerHostname: String?
) async throws -> HTTP2Connection {
fatalError("\(#function) called unexpectedly")
}
Expand Down Expand Up @@ -103,7 +103,7 @@ struct NIOPosixConnector: HTTP2Connector {

func establishConnection(
to address: GRPCNIOTransportCore.SocketAddress,
authority: String?
sniServerHostname: String?
) async throws -> HTTP2Connection {
return try await ClientBootstrap(group: self.eventLoopGroup).connect(to: address) { channel in
channel.eventLoop.makeCompletedFuture {
Expand Down

0 comments on commit b07aa65

Please sign in to comment.