Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions Sources/GRPCNIOTransportCore/Client/Connection/Connection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,39 @@ package final class Connection: Sendable {
self.event.stream
}

private static func sanitizeAuthorityForSNI(_ authority: String) -> String {
private static func sanitizeAuthorityForSNI(_ authority: String) -> String? {
// A bare IP literal is never a valid SNI hostname (RFC 6066 § 3 permits
// only DNS hostnames, and NIOSSL enforces this by failing the connection
// before the handshake starts). Check before stripping a port: the
// stripping below would otherwise mangle bare IPv6 literals like "::1".
if Self.isIPLiteral(authority) {
return nil
}

// 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")
}

let host: String
if let index = index, authority.utf8[index] == UInt8(ascii: ":") {
return String(authority.utf8[..<index])!
host = String(authority.utf8[..<index])!
} else {
return authority
host = authority
}

// Square brackets imply an IPv6 literal (e.g. "[2001:db8::1]:443"),
// which can't be used for SNI.
if host.utf8.first == UInt8(ascii: "["), host.utf8.last == UInt8(ascii: "]") {
return nil
}

return Self.isIPLiteral(host) ? nil : host
}

private static func isIPLiteral(_ host: String) -> Bool {
(try? NIOCore.SocketAddress(ipAddress: host, port: 0)) != nil
}

package init(
Expand All @@ -138,7 +159,7 @@ package final class Connection: Sendable {
) {
self.address = address
self.authority = authority
self.sniServerHostname = authority.map { Self.sanitizeAuthorityForSNI($0) }
self.sniServerHostname = authority.flatMap { Self.sanitizeAuthorityForSNI($0) }
self.defaultCompression = defaultCompression
self.enabledCompression = enabledCompression
self.http2Connector = http2Connector
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ final class ConnectionTests: XCTestCase {
}
}

private func testAuthorityIsSanitized(authority: String, expected: String) async throws {
private func testAuthorityIsSanitized(authority: String, expected: String?) async throws {
let recorder = SNIRecordingConnector()
let connection = Connection(
address: .ipv4(host: "ignored", port: 0),
Expand Down Expand Up @@ -239,6 +239,30 @@ final class ConnectionTests: XCTestCase {
expected: "foo.example.com:abc123"
)
}

func testAuthorityWithIPLiteralHasNoSNI() async throws {
// IP literals aren't valid SNI hostnames (RFC 6066 § 3) and NIOSSL
// refuses them, failing the connection before the handshake starts.
try await self.testAuthorityIsSanitized(
authority: "127.0.0.1",
expected: nil
)

try await self.testAuthorityIsSanitized(
authority: "127.0.0.1:9443",
expected: nil
)

try await self.testAuthorityIsSanitized(
authority: "::1",
expected: nil
)

try await self.testAuthorityIsSanitized(
authority: "[2001:db8::1]:443",
expected: nil
)
}
}

@available(gRPCSwiftNIOTransport 2.0, *)
Expand Down
Loading