Skip to content

Commit b07aa65

Browse files
authored
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
1 parent acbd55d commit b07aa65

File tree

6 files changed

+89
-10
lines changed

6 files changed

+89
-10
lines changed

Sources/GRPCNIOTransportCore/Client/Connection/Connection.swift

+19-1
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ package final class Connection: Sendable {
8989
/// being connected to.
9090
private let authority: String?
9191

92+
/// The name of the server used for the TLS SNI extension, if applicable.
93+
private let sniServerHostname: String?
94+
9295
/// The default compression algorithm used for requests.
9396
private let defaultCompression: CompressionAlgorithm
9497

@@ -111,6 +114,20 @@ package final class Connection: Sendable {
111114
self.event.stream
112115
}
113116

117+
private static func sanitizeAuthorityForSNI(_ authority: String) -> String {
118+
// Strip off a trailing ":{PORT}". Look for the last non-digit byte, if it's
119+
// a colon then keep everything up to that index.
120+
let index = authority.utf8.lastIndex { byte in
121+
return byte < UInt8(ascii: "0") || byte > UInt8(ascii: "9")
122+
}
123+
124+
if let index = index, authority.utf8[index] == UInt8(ascii: ":") {
125+
return String(authority.utf8[..<index])!
126+
} else {
127+
return authority
128+
}
129+
}
130+
114131
package init(
115132
address: SocketAddress,
116133
authority: String?,
@@ -120,6 +137,7 @@ package final class Connection: Sendable {
120137
) {
121138
self.address = address
122139
self.authority = authority
140+
self.sniServerHostname = authority.map { Self.sanitizeAuthorityForSNI($0) }
123141
self.defaultCompression = defaultCompression
124142
self.enabledCompression = enabledCompression
125143
self.http2Connector = http2Connector
@@ -140,7 +158,7 @@ package final class Connection: Sendable {
140158
// The authority here is used for the SNI hostname in the TLS handshake (if applicable)
141159
// where a raw IP address isn't permitted, so fallback to 'address.sniHostname' rather
142160
// than 'address.authority'.
143-
authority: self.authority ?? self.address.sniHostname
161+
sniServerHostname: self.sniServerHostname ?? self.address.sniHostname
144162
)
145163
} catch let error as RPCError {
146164
throw error

Sources/GRPCNIOTransportCore/Client/Connection/ConnectionFactory.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ package protocol HTTP2Connector: Sendable {
2323
///
2424
/// - Parameters:
2525
/// - address: The address to connect to.
26-
/// - authority: The authority as used for the TLS SNI extension (if applicable).
26+
/// - sniServerHostname: The name of the server used for the TLS SNI extension (if applicable).
2727
func establishConnection(
2828
to address: SocketAddress,
29-
authority: String?
29+
sniServerHostname: String?
3030
) async throws -> HTTP2Connection
3131
}
3232

Sources/GRPCNIOTransportHTTP2Posix/HTTP2ClientTransport+Posix.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ extension HTTP2ClientTransport.Posix {
165165

166166
func establishConnection(
167167
to address: GRPCNIOTransportCore.SocketAddress,
168-
authority: String?
168+
sniServerHostname: String?
169169
) async throws -> HTTP2Connection {
170170
let (channel, multiplexer) = try await ClientBootstrap(
171171
group: self.eventLoopGroup
@@ -175,7 +175,7 @@ extension HTTP2ClientTransport.Posix {
175175
try channel.pipeline.syncOperations.addHandler(
176176
NIOSSLClientHandler(
177177
context: sslContext,
178-
serverHostname: authority
178+
serverHostname: sniServerHostname
179179
)
180180
)
181181
}

Sources/GRPCNIOTransportHTTP2TransportServices/HTTP2ClientTransport+TransportServices.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ extension HTTP2ClientTransport.TransportServices {
149149

150150
func establishConnection(
151151
to address: GRPCNIOTransportCore.SocketAddress,
152-
authority: String?
152+
sniServerHostname: String?
153153
) async throws -> HTTP2Connection {
154154
let bootstrap: NIOTSConnectionBootstrap
155155
let isPlainText: Bool
@@ -162,7 +162,7 @@ extension HTTP2ClientTransport.TransportServices {
162162
case .tls(let tlsConfig):
163163
isPlainText = false
164164
do {
165-
let options = try NWProtocolTLS.Options(tlsConfig, authority: authority)
165+
let options = try NWProtocolTLS.Options(tlsConfig, authority: sniServerHostname)
166166
bootstrap = NIOTSConnectionBootstrap(group: self.eventLoopGroup)
167167
.channelOption(NIOTSChannelOptions.waitForActivity, value: false)
168168
.tlsOptions(options)

Tests/GRPCNIOTransportCoreTests/Client/Connection/ConnectionTests.swift

+61
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import NIOCore
2121
import NIOHPACK
2222
import NIOHTTP2
2323
import NIOPosix
24+
import Synchronization
2425
import XCTest
2526

2627
final class ConnectionTests: XCTestCase {
@@ -199,6 +200,44 @@ final class ConnectionTests: XCTestCase {
199200
XCTAssertEqual(error.code, .unavailable)
200201
}
201202
}
203+
204+
private func testAuthorityIsSanitized(authority: String, expected: String) async throws {
205+
let recorder = SNIRecordingConnector()
206+
let connection = Connection(
207+
address: .ipv4(host: "ignored", port: 0),
208+
authority: authority,
209+
http2Connector: recorder,
210+
defaultCompression: .none,
211+
enabledCompression: .none
212+
)
213+
214+
// The connect attempt will fail, but as a side effect the SNI hostname
215+
// will be recorded.
216+
await connection.run()
217+
XCTAssertEqual(recorder.sniHostnames, [expected])
218+
}
219+
220+
func testAuthorityIsSanitized() async throws {
221+
try await self.testAuthorityIsSanitized(
222+
authority: "foo.example.com",
223+
expected: "foo.example.com"
224+
)
225+
226+
try await self.testAuthorityIsSanitized(
227+
authority: "foo.example.com:31415",
228+
expected: "foo.example.com"
229+
)
230+
231+
try await self.testAuthorityIsSanitized(
232+
authority: "foo.example-31415",
233+
expected: "foo.example-31415"
234+
)
235+
236+
try await self.testAuthorityIsSanitized(
237+
authority: "foo.example.com:abc123",
238+
expected: "foo.example.com:abc123"
239+
)
240+
}
202241
}
203242

204243
extension ClientBootstrap {
@@ -252,3 +291,25 @@ extension Metadata {
252291
self = metadata
253292
}
254293
}
294+
295+
final class SNIRecordingConnector: HTTP2Connector {
296+
private let _sniHostnames: Mutex<[String?]>
297+
298+
var sniHostnames: [String?] {
299+
self._sniHostnames.withLock { $0 }
300+
}
301+
302+
init() {
303+
self._sniHostnames = Mutex([])
304+
}
305+
306+
func establishConnection(
307+
to address: GRPCNIOTransportCore.SocketAddress,
308+
sniServerHostname: String?
309+
) async throws -> GRPCNIOTransportCore.HTTP2Connection {
310+
self._sniHostnames.withLock {
311+
$0.append(sniServerHostname)
312+
}
313+
throw RPCError(code: .unavailable, message: "Test is expected to throw.")
314+
}
315+
}

Tests/GRPCNIOTransportCoreTests/Client/Connection/Utilities/HTTP2Connectors.swift

+3-3
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ struct ThrowingConnector: HTTP2Connector {
6262

6363
func establishConnection(
6464
to address: GRPCNIOTransportCore.SocketAddress,
65-
authority: String?
65+
sniServerHostname: String?
6666
) async throws -> HTTP2Connection {
6767
throw self.error
6868
}
@@ -71,7 +71,7 @@ struct ThrowingConnector: HTTP2Connector {
7171
struct NeverConnector: HTTP2Connector {
7272
func establishConnection(
7373
to address: GRPCNIOTransportCore.SocketAddress,
74-
authority: String?
74+
sniServerHostname: String?
7575
) async throws -> HTTP2Connection {
7676
fatalError("\(#function) called unexpectedly")
7777
}
@@ -103,7 +103,7 @@ struct NIOPosixConnector: HTTP2Connector {
103103

104104
func establishConnection(
105105
to address: GRPCNIOTransportCore.SocketAddress,
106-
authority: String?
106+
sniServerHostname: String?
107107
) async throws -> HTTP2Connection {
108108
return try await ClientBootstrap(group: self.eventLoopGroup).connect(to: address) { channel in
109109
channel.eventLoop.makeCompletedFuture {

0 commit comments

Comments
 (0)