From 2685e0f83ab0cade29107855eb6f57197a434744 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:16:22 -0300 Subject: [PATCH 1/2] Add pluggable trust-verification hooks for NIOSSL and Network.framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes HTTPClient.Configuration.tlsCustomVerification (NIOSSL backend) and .tlsCustomVerificationNetworkFramework (Network.framework backend), thin passthroughs into NIOSSLClientHandler's customVerificationCallback and Network.framework's sec_protocol_options_set_verify_block respectively. Neither hook carries any built-in pinning policy — the goal is to let a caller (e.g. a TrustEvaluator implemented downstream) fully own the accept/reject decision on whichever TLS backend actually negotiates the connection, including inspecting the full presented chain rather than just the leaf. Co-Authored-By: Claude Sonnet 5 --- .../HTTPConnectionPool+Factory.swift | 21 +- Sources/AsyncHTTPClient/HTTPClient.swift | 39 ++++ .../TLSConfiguration.swift | 48 +++- .../TrustCustomVerificationTests.swift | 213 ++++++++++++++++++ 4 files changed, 313 insertions(+), 8 deletions(-) create mode 100644 Tests/AsyncHTTPClientTests/TrustCustomVerificationTests.swift diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index f26fc8c08..5fcf11b6a 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -45,6 +45,20 @@ extension HTTPConnectionPool { self.tlsConfiguration = tlsConfiguration ?? clientConfiguration.tlsConfiguration ?? .makeClientConfiguration() } + + /// Builds a ``NIOSSLClientHandler`` for `context`/`serverHostname`, routing through + /// `clientConfiguration.tlsCustomVerification` when the caller has installed one — see that + /// property's documentation for what setting it implies for NIOSSL's own verification logic. + func makeNIOSSLClientHandler(context: NIOSSLContext, serverHostname: String?) throws -> NIOSSLClientHandler { + if let customVerification = self.clientConfiguration.tlsCustomVerification { + return try NIOSSLClientHandler( + context: context, + serverHostname: serverHostname, + customVerificationCallback: customVerification + ) + } + return try NIOSSLClientHandler(context: context, serverHostname: serverHostname) + } } } @@ -406,7 +420,7 @@ extension HTTPConnectionPool.ConnectionFactory { return sslContextFuture.flatMap { sslContext -> EventLoopFuture in do { - let sslHandler = try NIOSSLClientHandler( + let sslHandler = try self.makeNIOSSLClientHandler( context: sslContext, serverHostname: sslServerHostname ) @@ -591,7 +605,8 @@ extension HTTPConnectionPool.ConnectionFactory { let localAddr = self.key.localAddress let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions( on: eventLoop, - serverNameIndicatorOverride: key.serverNameIndicatorOverride + serverNameIndicatorOverride: key.serverNameIndicatorOverride, + customVerification: self.clientConfiguration.tlsCustomVerificationNetworkFramework ).map { options -> NIOClientTCPBootstrapProtocol in @@ -665,7 +680,7 @@ extension HTTPConnectionPool.ConnectionFactory { sslContextFuture.flatMap { sslContext -> EventLoopFuture in do { let sync = channel.pipeline.syncOperations - let sslHandler = try NIOSSLClientHandler( + let sslHandler = try self.makeNIOSSLClientHandler( context: sslContext, serverHostname: self.key.serverNameIndicator ) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index cc8792497..a8534fc14 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -26,6 +26,7 @@ import Tracing #if canImport(Network) import NIOTransportServices +import Security #endif #if canImport(FoundationEssentials) @@ -945,6 +946,40 @@ public final class HTTPClient: Sendable { /// Configuration how distributed traces are created and handled. public var tracing: TracingConfiguration = .init() + /// A callback that can completely override peer certificate verification for connections that use + /// the NIOSSL TLS backend — every connection on non-Apple platforms, and on Apple platforms every + /// proxied connection plus any direct connection that isn't running on Network.framework (see + /// ``tlsCustomVerificationNetworkFramework`` for that case). + /// + /// The callback receives the certificate chain presented by the peer (leaf first) and an + /// `EventLoopPromise` that must be completed exactly once to signal the verification result. + /// + /// - Warning: Setting this overrides *all* trust-chain verification logic NIOSSL provides. It + /// does **not**, on its own, disable hostname/SNI validation — that check is a separate NIOSSL + /// step gated purely by ``TLSConfiguration/certificateVerification``, and runs whenever that is + /// `.fullVerification` regardless of whether this callback is set. A conforming implementation + /// that wants to own hostname matching too must also set `tlsConfiguration.certificateVerification` + /// to `.none` or `.noHostnameVerification`. See ``NIOSSLCustomVerificationCallback`` for the full + /// contract a conforming implementation must uphold to remain secure. + public var tlsCustomVerification: + (@Sendable ([NIOSSLCertificate], EventLoopPromise) -> Void)? + + #if canImport(Network) + /// A callback that can completely override peer certificate verification for direct (non-proxied) + /// connections on Apple platforms that use Network.framework instead of NIOSSL (see + /// ``tlsCustomVerification`` for the NIOSSL backend used everywhere else, including every proxied + /// connection regardless of platform). + /// + /// The callback receives the peer's `SecTrust` and a completion handler that must be invoked + /// exactly once — with `true` to accept the connection, `false` to reject it. The completion + /// handler may be invoked asynchronously (e.g. after an OCSP lookup) from any thread. + /// + /// - Warning: Setting this overrides *all* verification logic Network.framework provides, + /// including trust-root validation. + public var tlsCustomVerificationNetworkFramework: + (@Sendable (SecTrust, @escaping @Sendable (Bool) -> Void) -> Void)? + #endif + public init( tlsConfiguration: TLSConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, @@ -964,6 +999,10 @@ public final class HTTPClient: Sendable { self.networkFrameworkWaitForConnectivity = true self.enableMultipath = false self.localAddress = nil + self.tlsCustomVerification = nil + #if canImport(Network) + self.tlsCustomVerificationNetworkFramework = nil + #endif } public init( diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift index ad3e65074..3236c3b7b 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift @@ -62,6 +62,14 @@ extension TLSVersion { } } +/// Wraps a non-`Sendable` value that is, in practice, safe to hand across threads — used to satisfy the +/// compiler when passing the C-provided `sec_protocol_verify_complete_t` completion handler into a +/// `@Sendable` closure. Network.framework's own contract for `sec_protocol_verify_block_t` guarantees +/// this handler may be invoked from any queue. +private struct UncheckedSendableBox: @unchecked Sendable { + let value: Value +} + @available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 5.0, *) extension TLSConfiguration { /// Dispatch queue used by Network framework TLS to control certificate verification @@ -70,15 +78,22 @@ extension TLSConfiguration { /// create NWProtocolTLS.Options for use with NIOTransportServices from the NIOSSL TLSConfiguration /// /// - Parameter eventLoop: EventLoop to wait for creation of options on + /// - Parameter customVerification: When non-nil, overrides *all* of Network.framework's certificate + /// verification (including trust-root validation) with this callback — see + /// ``HTTPClient/Configuration/tlsCustomVerificationNetworkFramework``. /// - Returns: Future holding NWProtocolTLS Options func getNWProtocolTLSOptions( on eventLoop: EventLoop, - serverNameIndicatorOverride: String? + serverNameIndicatorOverride: String?, + customVerification: (@Sendable (SecTrust, @escaping @Sendable (Bool) -> Void) -> Void)? = nil ) -> EventLoopFuture { let promise = eventLoop.makePromise(of: NWProtocolTLS.Options.self) Self.tlsDispatchQueue.async { do { - let options = try self.getNWProtocolTLSOptions(serverNameIndicatorOverride: serverNameIndicatorOverride) + let options = try self.getNWProtocolTLSOptions( + serverNameIndicatorOverride: serverNameIndicatorOverride, + customVerification: customVerification + ) promise.succeed(options) } catch { promise.fail(error) @@ -89,8 +104,14 @@ extension TLSConfiguration { /// create NWProtocolTLS.Options for use with NIOTransportServices from the NIOSSL TLSConfiguration /// + /// - Parameter customVerification: When non-nil, overrides *all* of Network.framework's certificate + /// verification (including trust-root validation) with this callback — see + /// ``HTTPClient/Configuration/tlsCustomVerificationNetworkFramework``. /// - Returns: Equivalent NWProtocolTLS Options - func getNWProtocolTLSOptions(serverNameIndicatorOverride: String?) throws -> NWProtocolTLS.Options { + func getNWProtocolTLSOptions( + serverNameIndicatorOverride: String?, + customVerification: (@Sendable (SecTrust, @escaping @Sendable (Bool) -> Void) -> Void)? = nil + ) throws -> NWProtocolTLS.Options { let options = NWProtocolTLS.Options() let useMTELGExplainer = """ @@ -178,12 +199,29 @@ extension TLSConfiguration { break } + // A custom verification callback takes over the accept/reject decision entirely, so the + // limitations around the built-in trust-root/hostname logic below no longer apply. precondition( - self.certificateVerification != .noHostnameVerification, + customVerification != nil || self.certificateVerification != .noHostnameVerification, "TLSConfiguration.certificateVerification = .noHostnameVerification is not supported. \(useMTELGExplainer)" ) - if certificateVerification != .fullVerification || trustRoots != nil { + if let customVerification { + // A caller-supplied callback overrides all of Network.framework's verification logic, + // including trust-root validation — same contract as NIOSSLCustomVerificationCallback on + // the NIOSSL backend. The callback owns the accept/reject decision entirely. + sec_protocol_options_set_verify_block( + options.securityProtocolOptions, + { _, sec_trust, sec_protocol_verify_complete in + let trust = sec_trust_copy_ref(sec_trust).takeRetainedValue() + let completeBox = UncheckedSendableBox(value: sec_protocol_verify_complete) + customVerification(trust) { accepted in + completeBox.value(accepted) + } + }, + Self.tlsDispatchQueue + ) + } else if certificateVerification != .fullVerification || trustRoots != nil { // add verify block to control certificate verification sec_protocol_options_set_verify_block( options.securityProtocolOptions, diff --git a/Tests/AsyncHTTPClientTests/TrustCustomVerificationTests.swift b/Tests/AsyncHTTPClientTests/TrustCustomVerificationTests.swift new file mode 100644 index 000000000..ff992e27f --- /dev/null +++ b/Tests/AsyncHTTPClientTests/TrustCustomVerificationTests.swift @@ -0,0 +1,213 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOConcurrencyHelpers +import NIOCore +import NIOSSL +import XCTest + +@testable import AsyncHTTPClient + +#if canImport(Network) +import Network +import Security +#endif + +/// Tests for `HTTPClient.Configuration.tlsCustomVerification` (NIOSSL backend) and +/// `HTTPClient.Configuration.tlsCustomVerificationNetworkFramework` (Network.framework backend) — the +/// hooks that let a caller fully replace certificate verification, on whichever TLS backend is actually +/// negotiating the connection. +final class TrustCustomVerificationTests: XCTestCase { + var clientGroup: EventLoopGroup! + + override func setUp() { + XCTAssertNil(self.clientGroup) + self.clientGroup = getDefaultEventLoopGroup(numberOfThreads: 3) + } + + override func tearDown() { + XCTAssertNotNil(self.clientGroup) + XCTAssertNoThrow(try self.clientGroup.syncShutdownGracefully()) + self.clientGroup = nil + } + + // MARK: - NIOSSL backend + + func testNIOSSLCustomVerificationIsInvokedAndCanAcceptAnUntrustedChain() throws { + // This only exercises the NIOSSL backend. + guard !isTestingNIOTS() else { return } + + let invocationCount = NIOLockedValueBox(0) + + // tlsCustomVerification only overrides trust-chain verification — hostname/SNI matching is a + // separate NIOSSL gate tied to certificateVerification (see the property's doc comment), and the + // HTTPBin test certificate isn't issued for "localhost". Disabling it here isolates the behavior + // this test actually cares about: that the callback, not the default chain check, decides trust. + var tlsConfig = TLSConfiguration.makeClientConfiguration() + tlsConfig.certificateVerification = .noHostnameVerification + + var config = HTTPClient.Configuration(tlsConfiguration: tlsConfig) + config.tlsCustomVerification = { certificates, promise in + invocationCount.withLockedValue { $0 += 1 } + // The self-signed leaf HTTPBin presents would fail default trust-root validation — proving + // this callback fully replaces it, not merely supplements it. + XCTAssertFalse(certificates.isEmpty) + promise.succeed(.certificateVerified) + } + + let httpBin = HTTPBin(.http1_1(ssl: true)) + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + XCTAssertNoThrow(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) + XCTAssertEqual(invocationCount.withLockedValue { $0 }, 1) + } + + func testNIOSSLCustomVerificationCanRejectAnOtherwiseTrustedConnection() throws { + guard !isTestingNIOTS() else { return } + + var config = HTTPClient.Configuration(timeout: .init(connect: .milliseconds(200))) + config.tlsCustomVerification = { _, promise in + promise.succeed(.failed) + } + + let httpBin = HTTPBin(.http1_1(ssl: true)) + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + XCTAssertThrowsError(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) { error in + guard let sslError = error as? NIOSSLError, case .handshakeFailed = sslError else { + XCTFail("Expected NIOSSLError.handshakeFailed, got \(error)") + return + } + } + } + + func testMTLSClientCertificateStillPresentedAlongsideNIOSSLCustomVerification() throws { + // This only exercises the NIOSSL backend — client certificates aren't supported over + // Network.framework at all (see the preconditionFailure in getNWProtocolTLSOptions). + guard !isTestingNIOTS() else { return } + + // The server requires and validates a client certificate, trusting only TestTLS.certificate + // itself (it's self-signed, so it is its own trust anchor) — this is mTLS, orthogonal to the + // question this test actually asks: does presenting a client identity still work once the + // client also installs a custom server-trust-verification callback? + var serverConfig = TestTLS.serverConfiguration + serverConfig.certificateVerification = .noHostnameVerification + serverConfig.trustRoots = .certificates([TestTLS.certificate]) + + var clientTLSConfig = TLSConfiguration.makeClientConfiguration() + clientTLSConfig.certificateVerification = .noHostnameVerification + clientTLSConfig.certificateChain = [.certificate(TestTLS.certificate)] + clientTLSConfig.privateKey = .privateKey(TestTLS.privateKey) + + let invocationCount = NIOLockedValueBox(0) + var config = HTTPClient.Configuration(tlsConfiguration: clientTLSConfig) + config.tlsCustomVerification = { certificates, promise in + invocationCount.withLockedValue { $0 += 1 } + XCTAssertFalse(certificates.isEmpty) + promise.succeed(.certificateVerified) + } + + let httpBin = HTTPBin(.http1_1(tlsConfiguration: serverConfig)) + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + // If the client failed to present its certificate, the server would reject the handshake + // (SSL_VERIFY_FAIL_IF_NO_PEER_CERT) and this would throw instead of succeeding. + XCTAssertNoThrow(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) + XCTAssertEqual(invocationCount.withLockedValue { $0 }, 1) + } + + // MARK: - Network.framework backend + + func testNetworkFrameworkCustomVerificationIsInvokedAndCanAcceptAnUntrustedChain() throws { + guard isTestingNIOTS() else { return } + #if canImport(Network) + let invocationCount = NIOLockedValueBox(0) + + var config = HTTPClient.Configuration() + config.tlsCustomVerificationNetworkFramework = { trust, complete in + invocationCount.withLockedValue { $0 += 1 } + XCTAssertFalse((SecTrustCopyCertificateChain(trust) as? [SecCertificate] ?? []).isEmpty) + complete(true) + } + + let httpBin = HTTPBin(.http1_1(ssl: true)) + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + XCTAssertNoThrow(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) + XCTAssertEqual(invocationCount.withLockedValue { $0 }, 1) + #endif + } + + func testNetworkFrameworkCustomVerificationCanRejectAnOtherwiseTrustedConnection() throws { + guard isTestingNIOTS() else { return } + #if canImport(Network) + var config = HTTPClient.Configuration() + config.tlsCustomVerificationNetworkFramework = { _, complete in + complete(false) + } + config = config.enableFastFailureModeForTesting() + + let httpBin = HTTPBin(.http1_1(ssl: true)) + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + XCTAssertThrowsError(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) { error in + XCTAssertTrue( + error is HTTPClient.NWTLSError, + "Expected HTTPClient.NWTLSError, got \(type(of: error))" + ) + } + #endif + } + + // MARK: - Plumbing (no network I/O) + + func testGetNWProtocolTLSOptionsInstallsCustomVerifyBlockWhenProvided() throws { + #if canImport(Network) + guard #available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *) else { + throw XCTSkip("Network.framework not available") + } + let tlsConfig = TLSConfiguration.makeClientConfiguration() + // Installing a callback must not throw, and must take precedence over the default trust-root + // verify block that would otherwise be installed since certificateVerification is unchanged. + XCTAssertNoThrow( + try tlsConfig.getNWProtocolTLSOptions( + serverNameIndicatorOverride: nil, + customVerification: { _, complete in complete(true) } + ) + ) + #else + throw XCTSkip("Network.framework not available") + #endif + } +} From a428d5f9c32efaf39ddfa5bbdf2eebb7cb7b45d0 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:12:34 -0300 Subject: [PATCH 2/2] Fix DocC symbol-link resolution failures in tlsCustomVerification's doc comment Three double-backtick links in an unconditional doc comment pointed at symbols that don't exist in every build this repo's CI produces documentation for: tlsCustomVerificationNetworkFramework is canImport(Network)-gated (absent from the Linux symbol graph entirely), and TLSConfiguration.certificateVerification / NIOSSLCustomVerificationCallback both live in the NIOSSL module, which DocC can't resolve an unqualified cross-module link against in this build. Switched all three to plain code spans -- still readable, without a resolution DocC can't perform. Co-Authored-By: Claude Sonnet 5 --- Sources/AsyncHTTPClient/HTTPClient.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index a8534fc14..c5834d93a 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -949,18 +949,18 @@ public final class HTTPClient: Sendable { /// A callback that can completely override peer certificate verification for connections that use /// the NIOSSL TLS backend — every connection on non-Apple platforms, and on Apple platforms every /// proxied connection plus any direct connection that isn't running on Network.framework (see - /// ``tlsCustomVerificationNetworkFramework`` for that case). + /// `tlsCustomVerificationNetworkFramework` for that case, on platforms where it's available). /// /// The callback receives the certificate chain presented by the peer (leaf first) and an /// `EventLoopPromise` that must be completed exactly once to signal the verification result. /// /// - Warning: Setting this overrides *all* trust-chain verification logic NIOSSL provides. It /// does **not**, on its own, disable hostname/SNI validation — that check is a separate NIOSSL - /// step gated purely by ``TLSConfiguration/certificateVerification``, and runs whenever that is + /// step gated purely by `TLSConfiguration.certificateVerification`, and runs whenever that is /// `.fullVerification` regardless of whether this callback is set. A conforming implementation /// that wants to own hostname matching too must also set `tlsConfiguration.certificateVerification` - /// to `.none` or `.noHostnameVerification`. See ``NIOSSLCustomVerificationCallback`` for the full - /// contract a conforming implementation must uphold to remain secure. + /// to `.none` or `.noHostnameVerification`. See `NIOSSLCustomVerificationCallback` (from NIOSSL) for + /// the full contract a conforming implementation must uphold to remain secure. public var tlsCustomVerification: (@Sendable ([NIOSSLCertificate], EventLoopPromise) -> Void)?