Skip to content

Commit 90302d3

Browse files
dmonagleclaude
andcommitted
Don't use IP literals for TLS SNI
An authority containing an IP literal (e.g. a .dns target created with an IP address, common when callers resolve addresses themselves) was passed to the TLS handshake as the SNI server hostname. RFC 6066 § 3 only permits DNS hostnames in SNI and NIOSSL enforces this, so such connections failed with cannotUseIPAddressInSNI before the handshake started. SocketAddress.sniHostname already applies this rule; the authority-derived hostname now does too: IP literals (bare v4/v6, with a port, or bracketed v6) yield no SNI instead of a handshake failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2ca31f0 commit 90302d3

2 files changed

Lines changed: 50 additions & 5 deletions

File tree

Sources/GRPCNIOTransportCore/Client/Connection/Connection.swift

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,18 +115,39 @@ package final class Connection: Sendable {
115115
self.event.stream
116116
}
117117

118-
private static func sanitizeAuthorityForSNI(_ authority: String) -> String {
118+
private static func sanitizeAuthorityForSNI(_ authority: String) -> String? {
119+
// A bare IP literal is never a valid SNI hostname (RFC 6066 § 3 permits
120+
// only DNS hostnames, and NIOSSL enforces this by failing the connection
121+
// before the handshake starts). Check before stripping a port: the
122+
// stripping below would otherwise mangle bare IPv6 literals like "::1".
123+
if Self.isIPLiteral(authority) {
124+
return nil
125+
}
126+
119127
// Strip off a trailing ":{PORT}". Look for the last non-digit byte, if it's
120128
// a colon then keep everything up to that index.
121129
let index = authority.utf8.lastIndex { byte in
122130
return byte < UInt8(ascii: "0") || byte > UInt8(ascii: "9")
123131
}
124132

133+
var host: String
125134
if let index = index, authority.utf8[index] == UInt8(ascii: ":") {
126-
return String(authority.utf8[..<index])!
135+
host = String(authority.utf8[..<index])!
127136
} else {
128-
return authority
137+
host = authority
138+
}
139+
140+
// Undo the bracketing applied to IPv6 addresses in authorities
141+
// (e.g. "[2001:db8::1]:443").
142+
if host.utf8.first == UInt8(ascii: "["), host.utf8.last == UInt8(ascii: "]") {
143+
host = String(host.dropFirst().dropLast())
129144
}
145+
146+
return Self.isIPLiteral(host) ? nil : host
147+
}
148+
149+
private static func isIPLiteral(_ host: String) -> Bool {
150+
(try? NIOCore.SocketAddress(ipAddress: host, port: 0)) != nil
130151
}
131152

132153
package init(
@@ -138,7 +159,7 @@ package final class Connection: Sendable {
138159
) {
139160
self.address = address
140161
self.authority = authority
141-
self.sniServerHostname = authority.map { Self.sanitizeAuthorityForSNI($0) }
162+
self.sniServerHostname = authority.flatMap { Self.sanitizeAuthorityForSNI($0) }
142163
self.defaultCompression = defaultCompression
143164
self.enabledCompression = enabledCompression
144165
self.http2Connector = http2Connector

Tests/GRPCNIOTransportCoreTests/Client/Connection/ConnectionTests.swift

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ final class ConnectionTests: XCTestCase {
202202
}
203203
}
204204

205-
private func testAuthorityIsSanitized(authority: String, expected: String) async throws {
205+
private func testAuthorityIsSanitized(authority: String, expected: String?) async throws {
206206
let recorder = SNIRecordingConnector()
207207
let connection = Connection(
208208
address: .ipv4(host: "ignored", port: 0),
@@ -239,6 +239,30 @@ final class ConnectionTests: XCTestCase {
239239
expected: "foo.example.com:abc123"
240240
)
241241
}
242+
243+
func testAuthorityWithIPLiteralHasNoSNI() async throws {
244+
// IP literals aren't valid SNI hostnames (RFC 6066 § 3) and NIOSSL
245+
// refuses them, failing the connection before the handshake starts.
246+
try await self.testAuthorityIsSanitized(
247+
authority: "127.0.0.1",
248+
expected: nil
249+
)
250+
251+
try await self.testAuthorityIsSanitized(
252+
authority: "127.0.0.1:9443",
253+
expected: nil
254+
)
255+
256+
try await self.testAuthorityIsSanitized(
257+
authority: "::1",
258+
expected: nil
259+
)
260+
261+
try await self.testAuthorityIsSanitized(
262+
authority: "[2001:db8::1]:443",
263+
expected: nil
264+
)
265+
}
242266
}
243267

244268
@available(gRPCSwiftNIOTransport 2.0, *)

0 commit comments

Comments
 (0)