Skip to content

Commit c84ffd3

Browse files
josephnoir0xTim
andauthored
Expose peer certificates in request handlers (vapor#3362)
* Expose peer certificates in request handlers Additional certificate information can be relevant in mTLS deployments. Exposing the validated certificate chain to the peer makes them available per request. This PR exposes the validated peer certificate chain, i.e., the certificates that establish trust of the peer identity (from the leaf to and including the root certificate). * Address API breakage * Add Swift ASN1 to server tests --------- Co-authored-by: Tim Condon <[email protected]>
1 parent a69c0ea commit c84ffd3

File tree

6 files changed

+290
-10
lines changed

6 files changed

+290
-10
lines changed

Package.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ let package = Package(
3434
.package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"),
3535

3636
// Bindings to OpenSSL-compatible libraries for TLS support in SwiftNIO
37-
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.8.0"),
37+
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.34.0"),
3838

3939
// HTTP/2 support for SwiftNIO
4040
.package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.28.0"),
@@ -65,6 +65,12 @@ let package = Package(
6565

6666
// Low-level atomic operations
6767
.package(url: "https://github.com/apple/swift-atomics.git", from: "1.1.0"),
68+
69+
// X509 certificate types for the Swift ecosystem
70+
.package(url: "https://github.com/apple/swift-certificates.git", from: "1.14.0"),
71+
72+
// Work with certificate encoding schemes
73+
.package(url: "https://github.com/apple/swift-asn1.git", from: "1.0.0")
6874
],
6975
targets: [
7076
// C helpers
@@ -100,6 +106,8 @@ let package = Package(
100106
.product(name: "Atomics", package: "swift-atomics"),
101107
.product(name: "_NIOFileSystem", package: "swift-nio"),
102108
.product(name: "_NIOFileSystemFoundationCompat", package: "swift-nio"),
109+
.product(name: "X509", package: "swift-certificates"),
110+
.product(name: "SwiftASN1", package: "swift-asn1"),
103111
],
104112
swiftSettings: swiftSettings
105113
),
@@ -142,6 +150,7 @@ let package = Package(
142150
name: "VaporTests",
143151
dependencies: [
144152
.product(name: "NIOTestUtils", package: "swift-nio"),
153+
.product(name: "SwiftASN1", package: "swift-asn1"),
145154
.target(name: "XCTVapor"),
146155
.target(name: "VaporTesting"),
147156
.target(name: "Vapor"),

Sources/Vapor/HTTP/Server/HTTPServer.swift

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,19 @@ public final class HTTPServer: Server, Sendable {
113113

114114
/// An optional callback that will be called instead of using swift-nio-ssl's regular certificate verification logic.
115115
/// This is the same as `NIOSSLCustomVerificationCallback` but just marked as `Sendable`
116+
/// - Warning: Mutually exclusive with `customCertificateVerifyCallbackWithMetadata`.
116117
@preconcurrency
117118
public var customCertificateVerifyCallback: (@Sendable ([NIOSSLCertificate], EventLoopPromise<NIOSSLVerificationResult>) -> Void)?
118-
119+
120+
/// An optional callback that will be called instead of using swift-nio-ssl's regular certificate verification logic.
121+
/// This is the same as `NIOSSLCustomVerificationCallbackWithMetadata` but just marked as `Sendable`.
122+
///
123+
/// In contrast to `customCertificateVerifyCallback`, this callback allows returning the validate certificate
124+
/// chain, which can then be accessed on the request via `Request.peerCertificateChain`.
125+
/// - Warning: Mutually exclusive with `customCertificateVerifyCallback`.
126+
@preconcurrency
127+
public var customCertificateVerifyCallbackWithMetadata: (@Sendable ([NIOSSLCertificate], EventLoopPromise<NIOSSLVerificationResultWithMetadata>) -> Void)?
128+
119129
/// The number of incoming TCP connections to accept per "tick" (i.e. each time through the server's event loop).
120130
///
121131
/// Most users will never need to change this value; its primary use case is to work around benchmarking
@@ -161,6 +171,43 @@ public final class HTTPServer: Server, Sendable {
161171
)
162172
}
163173

174+
public init(
175+
hostname: String = Self.defaultHostname,
176+
port: Int = Self.defaultPort,
177+
backlog: Int = 256,
178+
reuseAddress: Bool = true,
179+
tcpNoDelay: Bool = true,
180+
responseCompression: ResponseCompressionConfiguration = .disabled,
181+
requestDecompression: RequestDecompressionConfiguration = .enabled,
182+
supportPipelining: Bool = true,
183+
supportVersions: Set<HTTPVersionMajor>? = nil,
184+
tlsConfiguration: TLSConfiguration? = nil,
185+
serverName: String? = nil,
186+
reportMetrics: Bool = true,
187+
logger: Logger? = nil,
188+
shutdownTimeout: TimeAmount = .seconds(10),
189+
customCertificateVerifyCallbackWithMetadata: (@Sendable ([NIOSSLCertificate], EventLoopPromise<NIOSSLVerificationResultWithMetadata>) -> Void)?,
190+
connectionsPerServerTick: UInt = 256
191+
) {
192+
self.init(
193+
address: .hostname(hostname, port: port),
194+
backlog: backlog,
195+
reuseAddress: reuseAddress,
196+
tcpNoDelay: tcpNoDelay,
197+
responseCompression: responseCompression,
198+
requestDecompression: requestDecompression,
199+
supportPipelining: supportPipelining,
200+
supportVersions: supportVersions,
201+
tlsConfiguration: tlsConfiguration,
202+
serverName: serverName,
203+
reportMetrics: reportMetrics,
204+
logger: logger,
205+
shutdownTimeout: shutdownTimeout,
206+
customCertificateVerifyCallbackWithMetadata: customCertificateVerifyCallbackWithMetadata,
207+
connectionsPerServerTick: connectionsPerServerTick
208+
)
209+
}
210+
164211
public init(
165212
address: BindAddress,
166213
backlog: Int = 256,
@@ -196,10 +243,50 @@ public final class HTTPServer: Server, Sendable {
196243
self.logger = logger ?? Logger(label: "codes.vapor.http-server")
197244
self.shutdownTimeout = shutdownTimeout
198245
self.customCertificateVerifyCallback = customCertificateVerifyCallback
246+
self.customCertificateVerifyCallbackWithMetadata = nil
247+
self.connectionsPerServerTick = connectionsPerServerTick
248+
}
249+
250+
public init(
251+
address: BindAddress,
252+
backlog: Int = 256,
253+
reuseAddress: Bool = true,
254+
tcpNoDelay: Bool = true,
255+
responseCompression: ResponseCompressionConfiguration = .disabled,
256+
requestDecompression: RequestDecompressionConfiguration = .enabled,
257+
supportPipelining: Bool = true,
258+
supportVersions: Set<HTTPVersionMajor>? = nil,
259+
tlsConfiguration: TLSConfiguration? = nil,
260+
serverName: String? = nil,
261+
reportMetrics: Bool = true,
262+
logger: Logger? = nil,
263+
shutdownTimeout: TimeAmount = .seconds(10),
264+
customCertificateVerifyCallbackWithMetadata: (@Sendable ([NIOSSLCertificate], EventLoopPromise<NIOSSLVerificationResultWithMetadata>) -> Void)?,
265+
connectionsPerServerTick: UInt = 256
266+
) {
267+
self.address = address
268+
self.backlog = backlog
269+
self.reuseAddress = reuseAddress
270+
self.tcpNoDelay = tcpNoDelay
271+
self.responseCompression = responseCompression
272+
self.requestDecompression = requestDecompression
273+
self.supportPipelining = supportPipelining
274+
if let supportVersions = supportVersions {
275+
self.supportVersions = supportVersions
276+
} else {
277+
self.supportVersions = tlsConfiguration == nil ? [.one] : [.one, .two]
278+
}
279+
self.tlsConfiguration = tlsConfiguration
280+
self.serverName = serverName
281+
self.reportMetrics = reportMetrics
282+
self.logger = logger ?? Logger(label: "codes.vapor.http-server")
283+
self.shutdownTimeout = shutdownTimeout
284+
self.customCertificateVerifyCallback = nil
285+
self.customCertificateVerifyCallbackWithMetadata = customCertificateVerifyCallbackWithMetadata
199286
self.connectionsPerServerTick = connectionsPerServerTick
200287
}
201288
}
202-
289+
203290
public var onShutdown: EventLoopFuture<Void> {
204291
guard let connection = self.connection.withLockedValue({ $0 }) else {
205292
fatalError("Server has not started yet")
@@ -447,7 +534,11 @@ private final class HTTPServerConnection: Sendable {
447534
let tlsHandler: NIOSSLServerHandler
448535
do {
449536
sslContext = try NIOSSLContext(configuration: tlsConfiguration)
450-
tlsHandler = NIOSSLServerHandler(context: sslContext, customVerifyCallback: configuration.customCertificateVerifyCallback)
537+
tlsHandler = NIOSSLServerHandler(
538+
context: sslContext,
539+
customVerifyCallback: configuration.customCertificateVerifyCallback,
540+
customVerifyCallbackWithMetadata: configuration.customCertificateVerifyCallbackWithMetadata
541+
)
451542
} catch {
452543
configuration.logger.error("Could not configure TLS: \(error)")
453544
return channel.close(mode: .all)
@@ -662,9 +753,13 @@ extension ChannelPipeline {
662753

663754
// MARK: Helper function for constructing NIOSSLServerHandler.
664755
extension NIOSSLServerHandler {
665-
convenience init(context: NIOSSLContext, customVerifyCallback: NIOSSLCustomVerificationCallback?) {
756+
convenience init(context: NIOSSLContext, customVerifyCallback: NIOSSLCustomVerificationCallback?,
757+
customVerifyCallbackWithMetadata: NIOSSLCustomVerificationCallbackWithMetadata?) {
758+
precondition(customVerifyCallback == nil || customVerifyCallbackWithMetadata == nil, "Only one of customVerifyCallback and customVerifyCallbackWithMetadata can be used at a time.")
666759
if let callback = customVerifyCallback {
667760
self.init(context: context, customVerificationCallback: callback)
761+
} else if let callbackWithMetadata = customVerifyCallbackWithMetadata {
762+
self.init(context: context, customVerificationCallbackWithMetadata: callbackWithMetadata)
668763
} else {
669764
self.init(context: context)
670765
}

Sources/Vapor/HTTP/Server/HTTPServerRequestDecoder.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Logging
22
import NIOCore
33
import NIOHTTP1
44
import Foundation
5+
import X509
56

67
final class HTTPServerRequestDecoder: ChannelDuplexHandler, RemovableChannelHandler {
78
typealias InboundIn = HTTPServerRequestPart
@@ -23,11 +24,30 @@ final class HTTPServerRequestDecoder: ChannelDuplexHandler, RemovableChannelHand
2324
self.application.logger
2425
}
2526
var application: Application
26-
27+
28+
enum CertificateChainCache {
29+
case miss
30+
case hit(ValidatedCertificateChain?)
31+
32+
mutating func lookup(_ updater: () throws -> ValidatedCertificateChain?) -> ValidatedCertificateChain? {
33+
switch self {
34+
case .miss:
35+
let result = try? updater()
36+
self = .hit(result)
37+
return result
38+
case .hit(let result):
39+
return result
40+
}
41+
}
42+
}
43+
44+
var validatedCertificateChainCache: CertificateChainCache
45+
2746
init(application: Application) {
2847
self.application = application
2948
self.requestState = .ready
3049
self.bodyStreamState = .init()
50+
self.validatedCertificateChainCache = .miss
3151
}
3252

3353
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
@@ -38,6 +58,11 @@ final class HTTPServerRequestDecoder: ChannelDuplexHandler, RemovableChannelHand
3858
case .head(let head):
3959
switch self.requestState {
4060
case .ready:
61+
// The certificate chain will only be avalable when configuring `customCertificateVerifyCallbackWithMetadata`.
62+
// Since the certificate chain is validated during the handshake, we only collect it once and cache it.
63+
let peerCertificateChain = self.validatedCertificateChainCache.lookup {
64+
try? context.pipeline.syncOperations.nioSSL_peerValidatedCertificateChain()?.usingX509Certificates()
65+
}
4166
/// Note: It is critical that `URI.init(path:)` is used here, _NOT_ `URI.init(string:)`. The following
4267
/// example illustrates why:
4368
///
@@ -57,6 +82,7 @@ final class HTTPServerRequestDecoder: ChannelDuplexHandler, RemovableChannelHand
5782
version: head.version,
5883
headersNoUpdate: head.headers,
5984
remoteAddress: context.channel.remoteAddress,
85+
peerCertificateChain: peerCertificateChain,
6086
logger: self.application.logger,
6187
byteBufferAllocator: context.channel.allocator,
6288
on: context.channel.eventLoop

Sources/Vapor/Request/Request.swift

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Logging
55
import RoutingKit
66
import NIOConcurrencyHelpers
77
import ServiceContextModule
8+
import X509
89

910
/// Represents an HTTP request in an application.
1011
public final class Request: CustomStringConvertible, Sendable {
@@ -97,6 +98,12 @@ public final class Request: CustomStringConvertible, Sendable {
9798
return self.remoteAddress
9899
}
99100

101+
/// The validated certificate chain. This returns nil if the peer did not authenticate with a certificate. Requires
102+
/// configuring a `customCertificateVerifyCallbackWithMetadata` that performs the verification.
103+
public var peerCertificateChain: ValidatedCertificateChain? {
104+
return self.requestBox.withLockedValue { $0.peerCertificateChain }
105+
}
106+
100107
// MARK: Content
101108

102109
private struct _URLQueryContainer: URLQueryContainer, Sendable {
@@ -276,6 +283,7 @@ public final class Request: CustomStringConvertible, Sendable {
276283
var isKeepAlive: Bool
277284
var route: Route?
278285
var parameters: Parameters
286+
var peerCertificateChain: ValidatedCertificateChain?
279287
var byteBufferAllocator: ByteBufferAllocator
280288
}
281289

@@ -313,7 +321,66 @@ public final class Request: CustomStringConvertible, Sendable {
313321
self.headers.updateContentLength(body.readableBytes)
314322
}
315323
}
316-
324+
325+
public convenience init(
326+
application: Application,
327+
method: HTTPMethod = .GET,
328+
url: URI = "/",
329+
version: HTTPVersion = .init(major: 1, minor: 1),
330+
headers: HTTPHeaders = .init(),
331+
collectedBody: ByteBuffer? = nil,
332+
remoteAddress: SocketAddress? = nil,
333+
peerCertificateChain: ValidatedCertificateChain?,
334+
logger: Logger = .init(label: "codes.vapor.request"),
335+
byteBufferAllocator: ByteBufferAllocator = ByteBufferAllocator(),
336+
on eventLoop: EventLoop
337+
) {
338+
self.init(
339+
application: application,
340+
method: method,
341+
url: url,
342+
version: version,
343+
headersNoUpdate: headers,
344+
collectedBody: collectedBody,
345+
remoteAddress: remoteAddress,
346+
peerCertificateChain: peerCertificateChain,
347+
logger: logger,
348+
byteBufferAllocator: byteBufferAllocator,
349+
on: eventLoop
350+
)
351+
if let body = collectedBody {
352+
self.headers.updateContentLength(body.readableBytes)
353+
}
354+
}
355+
356+
@_disfavoredOverload
357+
public convenience init(
358+
application: Application,
359+
method: HTTPMethod,
360+
url: URI,
361+
version: HTTPVersion = .init(major: 1, minor: 1),
362+
headersNoUpdate headers: HTTPHeaders = .init(),
363+
collectedBody: ByteBuffer? = nil,
364+
remoteAddress: SocketAddress? = nil,
365+
logger: Logger = .init(label: "codes.vapor.request"),
366+
byteBufferAllocator: ByteBufferAllocator = ByteBufferAllocator(),
367+
on eventLoop: EventLoop
368+
) {
369+
self.init(
370+
application: application,
371+
method: method,
372+
url: url,
373+
version: version,
374+
headersNoUpdate: headers,
375+
collectedBody: collectedBody,
376+
remoteAddress: remoteAddress,
377+
peerCertificateChain: nil,
378+
logger: logger,
379+
byteBufferAllocator: byteBufferAllocator,
380+
on: eventLoop
381+
)
382+
}
383+
317384
public init(
318385
application: Application,
319386
method: HTTPMethod,
@@ -322,6 +389,7 @@ public final class Request: CustomStringConvertible, Sendable {
322389
headersNoUpdate headers: HTTPHeaders = .init(),
323390
collectedBody: ByteBuffer? = nil,
324391
remoteAddress: SocketAddress? = nil,
392+
peerCertificateChain: ValidatedCertificateChain?,
325393
logger: Logger = .init(label: "codes.vapor.request"),
326394
byteBufferAllocator: ByteBufferAllocator = ByteBufferAllocator(),
327395
on eventLoop: EventLoop
@@ -347,6 +415,7 @@ public final class Request: CustomStringConvertible, Sendable {
347415
isKeepAlive: true,
348416
route: nil,
349417
parameters: .init(),
418+
peerCertificateChain: peerCertificateChain,
350419
byteBufferAllocator: byteBufferAllocator
351420
)
352421
self.requestBox = .init(storageBox)
@@ -359,7 +428,7 @@ public final class Request: CustomStringConvertible, Sendable {
359428
self._storage = .init(.init())
360429
self.bodyStorage = .init(bodyStorage)
361430
}
362-
431+
363432
/// Automatically restores tracing serviceContext around the provided closure
364433
func propagateTracingIfEnabled<T>(_ closure: () throws -> T) rethrows -> T {
365434
if self.traceAutoPropagation {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import X509
2+
import NIOSSL
3+
import SwiftASN1
4+
5+
extension NIOSSLCertificate {
6+
// Convert NIOSSL certificate to X509 certificate. Currently requires to go
7+
// to throught the DER representation. This only used in few cases and
8+
// should not impact the performance of most users.
9+
@inlinable
10+
func toX509Certificate() throws -> X509.Certificate {
11+
let derBytes = try self.toDERBytes()
12+
return try X509.Certificate(derEncoded: derBytes)
13+
}
14+
}
15+
16+
extension NIOSSL.ValidatedCertificateChain {
17+
// The precondition holds because the `NIOSSL.ValidatedCertificateChain` always contains one `NIOSSLCertificate`.
18+
@inlinable
19+
func usingX509Certificates() throws -> X509.ValidatedCertificateChain {
20+
// This is safe because we this certificate chain is verified in NIOSSL.
21+
return .init(uncheckedCertificateChain: try self.map { try $0.toX509Certificate() })
22+
}
23+
}

0 commit comments

Comments
 (0)