Skip to content

Commit ae24e15

Browse files
SWIFT-1162 MongoConnectionString TLS options support (#701)
1 parent ee74cee commit ae24e15

File tree

2 files changed

+235
-40
lines changed

2 files changed

+235
-40
lines changed

Sources/MongoSwift/MongoConnectionString.swift

Lines changed: 230 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import SwiftBSON
23

34
/// Represents a MongoDB connection string.
45
/// - SeeAlso: https://docs.mongodb.com/manual/reference/connection-string/
@@ -8,6 +9,22 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
89
/// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
910
fileprivate static let forbiddenUserInfoCharacters = [":", "/", "?", "#", "[", "]", "@"]
1011

12+
fileprivate enum OptionName: String {
13+
case authSource = "authsource"
14+
case authMechanism = "authmechanism"
15+
case authMechanismProperties = "authmechanismproperties"
16+
case ssl
17+
case tls
18+
case tlsAllowInvalidCertificates = "tlsallowinvalidcertificates"
19+
case tlsAllowInvalidHostnames = "tlsallowinvalidhostnames"
20+
case tlsCAFile = "tlscafile"
21+
case tlsCertificateKeyFile = "tlscertificatekeyfile"
22+
case tlsCertificateKeyFilePassword = "tlscertificatekeyfilepassword"
23+
case tlsDisableCertificateRevocationCheck = "tlsdisablecertificaterevocationcheck"
24+
case tlsDisableOCSPEndpointCheck = "tlsdisableocspendpointcheck"
25+
case tlsInsecure = "tlsinsecure"
26+
}
27+
1128
/// Represents a connection string scheme.
1229
public struct Scheme: LosslessStringConvertible, Equatable {
1330
/// Indicates that this connection string uses the scheme `mongodb`.
@@ -126,10 +143,23 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
126143
}
127144

128145
private struct Options {
146+
// Authentication options
129147
fileprivate var authSource: String?
130148
fileprivate var authMechanism: MongoCredential.Mechanism?
131149
fileprivate var authMechanismProperties: BSONDocument?
132150

151+
// TLS options
152+
fileprivate var ssl: Bool?
153+
fileprivate var tls: Bool?
154+
fileprivate var tlsAllowInvalidCertificates: Bool?
155+
fileprivate var tlsAllowInvalidHostnames: Bool?
156+
fileprivate var tlsCAFile: URL?
157+
fileprivate var tlsCertificateKeyFile: URL?
158+
fileprivate var tlsCertificateKeyFilePassword: String?
159+
fileprivate var tlsDisableCertificateRevocationCheck: Bool?
160+
fileprivate var tlsDisableOCSPEndpointCheck: Bool?
161+
fileprivate var tlsInsecure: Bool?
162+
133163
fileprivate init(_ uriOptions: String) throws {
134164
let options = uriOptions.components(separatedBy: "&")
135165
for option in options {
@@ -140,23 +170,45 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
140170
+ " equals signs"
141171
)
142172
}
143-
let name = nameAndValue[0].lowercased()
144-
let value = try nameAndValue[1].getPercentDecoded(forKey: name)
173+
guard let name = OptionName(rawValue: nameAndValue[0].lowercased()) else {
174+
throw MongoError.InvalidArgumentError(
175+
message: "Connection string contains unsupported option: \(nameAndValue[0])"
176+
)
177+
}
178+
let value = try nameAndValue[1].getPercentDecoded(forKey: name.rawValue)
145179
switch name {
146-
case "authsource":
180+
case .authSource:
147181
if value.isEmpty {
148182
throw MongoError.InvalidArgumentError(
149-
message: "Connection string authSource option must not be empty"
183+
message: "Connection string \(name.rawValue) option must not be empty"
150184
)
151185
}
152186
self.authSource = value
153-
case "authmechanism":
187+
case .authMechanism:
154188
self.authMechanism = try MongoCredential.Mechanism(value)
155-
case "authmechanismproperties":
189+
case .authMechanismProperties:
156190
self.authMechanismProperties = try self.parseAuthMechanismProperties(properties: value)
157-
default:
158-
// TODO: SWIFT-1163: error on unknown options
159-
break
191+
case .ssl:
192+
self.ssl = try value.getBool(forKey: name.rawValue)
193+
case .tls:
194+
self.tls = try value.getBool(forKey: name.rawValue)
195+
case .tlsAllowInvalidCertificates:
196+
self.tlsAllowInvalidCertificates = try value.getBool(forKey: name.rawValue)
197+
case .tlsAllowInvalidHostnames:
198+
self.tlsAllowInvalidHostnames = try value.getBool(forKey: name.rawValue)
199+
case .tlsCAFile:
200+
self.tlsCAFile = URL(string: value)
201+
case .tlsCertificateKeyFile:
202+
self.tlsCertificateKeyFile = URL(string: value)
203+
case .tlsCertificateKeyFilePassword:
204+
self.tlsCertificateKeyFilePassword = value
205+
case .tlsDisableCertificateRevocationCheck:
206+
self.tlsDisableCertificateRevocationCheck =
207+
try value.getBool(forKey: name.rawValue)
208+
case .tlsDisableOCSPEndpointCheck:
209+
self.tlsDisableOCSPEndpointCheck = try value.getBool(forKey: name.rawValue)
210+
case .tlsInsecure:
211+
self.tlsInsecure = try value.getBool(forKey: name.rawValue)
160212
}
161213
}
162214
}
@@ -175,16 +227,7 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
175227
case "service_name", "service_realm":
176228
propertiesDoc[kv[0]] = .string(kv[1])
177229
case "canonicalize_host_name":
178-
switch kv[1] {
179-
case "true":
180-
propertiesDoc[kv[0]] = .bool(true)
181-
case "false":
182-
propertiesDoc[kv[0]] = .bool(false)
183-
default:
184-
throw MongoError.InvalidArgumentError(
185-
message: "Value for CANONICALIZE_HOST_NAME in authMechanismProperties must be true or false"
186-
)
187-
}
230+
propertiesDoc[kv[0]] = .bool(try kv[1].getBool(forKey: "CANONICALIZE_HOST_NAME"))
188231
case let other:
189232
throw MongoError.InvalidArgumentError(message: "Unknown key for authMechanismProperties: \(other)")
190233
}
@@ -282,6 +325,13 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
282325
let options = try Options(authDatabaseAndOptions[1])
283326

284327
// Parse authentication options into a MongoCredential
328+
try self.validateAndUpdateCredential(options: options)
329+
330+
// Validate and set TLS options
331+
try self.validateAndSetTLSOptions(options: options)
332+
}
333+
334+
private mutating func validateAndUpdateCredential(options: Options) throws {
285335
if let mechanism = options.authMechanism {
286336
var credential = self.credential ?? MongoCredential()
287337
credential.source = options.authSource ?? mechanism.getDefaultSource(defaultAuthDB: self.defaultAuthDB)
@@ -311,6 +361,57 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
311361
}
312362
}
313363

364+
private mutating func validateAndSetTLSOptions(options: Options) throws {
365+
guard options.tlsInsecure == nil
366+
|| (options.tlsAllowInvalidCertificates == nil
367+
&& options.tlsAllowInvalidHostnames == nil
368+
&& options.tlsDisableCertificateRevocationCheck == nil
369+
&& options.tlsDisableOCSPEndpointCheck == nil)
370+
else {
371+
throw MongoError.InvalidArgumentError(
372+
message: "tlsAllowInvalidCertificates, tlsAllowInvalidHostnames, tlsDisableCertificateRevocationCheck,"
373+
+ " and tlsDisableOCSPEndpointCheck cannot be specified if tlsInsecure is specified in the"
374+
+ " connection string"
375+
)
376+
}
377+
guard !(options.tlsAllowInvalidCertificates != nil && options.tlsDisableOCSPEndpointCheck != nil) else {
378+
throw MongoError.InvalidArgumentError(
379+
message: "tlsAllowInvalidCertificates and tlsDisableOCSPEndpointCheck cannot both be specified in the"
380+
+ " connection string"
381+
)
382+
}
383+
guard !(options.tlsAllowInvalidCertificates != nil && options.tlsDisableCertificateRevocationCheck != nil)
384+
else {
385+
throw MongoError.InvalidArgumentError(
386+
message: "tlsAllowInvalidCertificates and tlsDisableCertificateRevocationCheck cannot both be"
387+
+ " specified in the connection string"
388+
)
389+
}
390+
guard !(options.tlsDisableOCSPEndpointCheck != nil
391+
&& options.tlsDisableCertificateRevocationCheck != nil)
392+
else {
393+
throw MongoError.InvalidArgumentError(
394+
message: "tlsDisableOCSPEndpointCheck and tlsDisableCertificateRevocationCheck cannot both be"
395+
+ " specified in the connection string"
396+
)
397+
}
398+
if let tls = options.tls, let ssl = options.ssl, tls != ssl {
399+
throw MongoError.InvalidArgumentError(
400+
message: "tls and ssl must have the same value if both are specified in the connection string"
401+
)
402+
}
403+
// if either tls or ssl is specified, the value should be stored in the tls field
404+
self.tls = options.tls ?? options.ssl
405+
self.tlsAllowInvalidCertificates = options.tlsAllowInvalidCertificates
406+
self.tlsAllowInvalidHostnames = options.tlsAllowInvalidHostnames
407+
self.tlsCAFile = options.tlsCAFile
408+
self.tlsCertificateKeyFile = options.tlsCertificateKeyFile
409+
self.tlsCertificateKeyFilePassword = options.tlsCertificateKeyFilePassword
410+
self.tlsDisableCertificateRevocationCheck = options.tlsDisableCertificateRevocationCheck
411+
self.tlsDisableOCSPEndpointCheck = options.tlsDisableOCSPEndpointCheck
412+
self.tlsInsecure = options.tlsInsecure
413+
}
414+
314415
/// `Codable` conformance
315416
public func encode(to encoder: Encoder) throws {
316417
var container = encoder.singleValueContainer()
@@ -343,6 +444,50 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
343444
return des
344445
}
345446

447+
/// Returns a document containing all of the options provided after the ? of the URI.
448+
internal var options: BSONDocument {
449+
var options = BSONDocument()
450+
451+
if let source = self.credential?.source {
452+
options[OptionName.authSource] = .string(source)
453+
}
454+
if let mechanism = self.credential?.mechanism {
455+
options[OptionName.authMechanism] = .string(mechanism.description)
456+
}
457+
if let properties = self.credential?.mechanismProperties {
458+
options[OptionName.authMechanismProperties] = .document(properties)
459+
}
460+
if let tls = self.tls {
461+
options[OptionName.tls] = .bool(tls)
462+
}
463+
if let tlsAllowInvalidCertificates = self.tlsAllowInvalidCertificates {
464+
options[OptionName.tlsAllowInvalidCertificates] = .bool(tlsAllowInvalidCertificates)
465+
}
466+
if let tlsAllowInvalidHostnames = self.tlsAllowInvalidHostnames {
467+
options[OptionName.tlsAllowInvalidHostnames] = .bool(tlsAllowInvalidHostnames)
468+
}
469+
if let tlsCAFile = self.tlsCAFile {
470+
options[OptionName.tlsCAFile] = .string(tlsCAFile.description)
471+
}
472+
if let tlsCertificateKeyFile = self.tlsCertificateKeyFile {
473+
options[OptionName.tlsCertificateKeyFile] = .string(tlsCertificateKeyFile.description)
474+
}
475+
if let tlsCertificateKeyFilePassword = self.tlsCertificateKeyFilePassword {
476+
options[OptionName.tlsCertificateKeyFilePassword] = .string(tlsCertificateKeyFilePassword)
477+
}
478+
if let tlsDisableCertificateRevocationCheck = self.tlsDisableCertificateRevocationCheck {
479+
options[OptionName.tlsDisableCertificateRevocationCheck] = .bool(tlsDisableCertificateRevocationCheck)
480+
}
481+
if let tlsDisableOCSPEndpointCheck = self.tlsDisableOCSPEndpointCheck {
482+
options[OptionName.tlsDisableOCSPEndpointCheck] = .bool(tlsDisableOCSPEndpointCheck)
483+
}
484+
if let tlsInsecure = self.tlsInsecure {
485+
options[OptionName.tlsInsecure] = .bool(tlsInsecure)
486+
}
487+
488+
return options
489+
}
490+
346491
/// Specifies the format this connection string is in.
347492
public var scheme: Scheme
348493

@@ -355,6 +500,47 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
355500

356501
/// Specifies the authentication credentials.
357502
public var credential: MongoCredential?
503+
504+
/// Specifies whether or not to require TLS for connections to the server. By default this is set to false.
505+
///
506+
/// - Note: Specifying any other "tls"-prefixed option will require TLS for connections to the server.
507+
public var tls: Bool?
508+
509+
/// Specifies whether to bypass validation of the certificate presented by the mongod/mongos instance. By default
510+
/// this is set to false.
511+
public var tlsAllowInvalidCertificates: Bool?
512+
513+
/// Specifies whether to disable hostname validation for the certificate presented by the mongod/mongos instance.
514+
/// By default this is set to false.
515+
public var tlsAllowInvalidHostnames: Bool?
516+
517+
/// Specifies the location of a local .pem file that contains the root certificate chain from the Certificate
518+
/// Authority. This file is used to validate the certificate presented by the mongod/mongos instance.
519+
public var tlsCAFile: URL?
520+
521+
/// Specifies the location of a local .pem file that contains either the client's TLS certificate or the client's
522+
/// TLS certificate and key. The client presents this file to the mongod/mongos instance.
523+
public var tlsCertificateKeyFile: URL?
524+
525+
/// Specifies the password to de-crypt the `tlsCertificateKeyFile`.
526+
public var tlsCertificateKeyFilePassword: String?
527+
528+
/// Specifies whether revocation checking (CRL / OCSP) should be disabled.
529+
/// On macOS, this setting has no effect.
530+
/// By default this is set to false.
531+
/// It is an error to specify both this option and `tlsDisableOCSPEndpointCheck`.
532+
public var tlsDisableCertificateRevocationCheck: Bool?
533+
534+
/// Indicates if OCSP responder endpoints should not be requested when an OCSP response is not stapled.
535+
/// On macOS, this setting has no effect.
536+
/// By default this is set to false.
537+
public var tlsDisableOCSPEndpointCheck: Bool?
538+
539+
/// When specified, TLS constraints will be relaxed as much as possible. Currently, setting this option to `true`
540+
/// is equivalent to setting `tlsAllowInvalidCertificates`, `tlsAllowInvalidHostnames`, and
541+
/// `tlsDisableCertificateRevocationCheck` to `true`.
542+
/// It is an error to specify both this option and any of the options enabled by it.
543+
public var tlsInsecure: Bool?
358544
}
359545

360546
extension StringProtocol {
@@ -377,4 +563,29 @@ extension StringProtocol {
377563
}
378564
return try self.getPercentDecoded(forKey: key)
379565
}
566+
567+
fileprivate func getBool(forKey key: String) throws -> Bool {
568+
switch self {
569+
case "true":
570+
return true
571+
case "false":
572+
return false
573+
default:
574+
throw MongoError.InvalidArgumentError(
575+
message: "Value for \(key) in connection string must be true or false"
576+
)
577+
}
578+
}
579+
}
580+
581+
/// Helper extension to set a document field with a `MongoConnectionString.Name`.
582+
extension BSONDocument {
583+
fileprivate subscript(name: MongoConnectionString.OptionName) -> BSON? {
584+
get {
585+
self[name.rawValue]
586+
}
587+
set {
588+
self[name.rawValue] = newValue
589+
}
590+
}
380591
}

Tests/MongoSwiftTests/ConnectionStringTests.swift

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ let skipUnsupported: [String: [String]] = [
8383
let skipMongoConnectionStringUnsupported: [String: [String]] = [
8484
// connection string tests
8585
"invalid-uris.json": ["option"],
86+
"valid-auth.json": ["at-signs in options aren't part of the userinfo"],
8687
"valid-options.json": ["*"],
8788
"valid-unix_socket-absolute.json": ["*"],
8889
"valid-unix_socket-relative.json": ["*"],
@@ -92,8 +93,7 @@ let skipMongoConnectionStringUnsupported: [String: [String]] = [
9293
"concern-options.json": ["*"],
9394
"read-preference-options.json": ["*"],
9495
"single-threaded-options.json": ["*"],
95-
"srv-options.json": ["*"],
96-
"tls-options.json": ["*"]
96+
"srv-options.json": ["*"]
9797
]
9898

9999
func shouldSkip(file: String, test: String) -> Bool {
@@ -162,27 +162,11 @@ final class ConnectionStringTests: MongoSwiftTestCase {
162162
}
163163
}
164164

165+
// Assert that options match, if present
165166
if let expectedOptions = testCase.options {
167+
let actualOptions = connString.options
166168
for (key, value) in expectedOptions {
167-
if key.lowercased() == "authmechanism" {
168-
// authMechanism is always specified as a string in the test JSON
169-
let expectedMechanism = value.stringValue!
170-
guard let actualMechanism = connString.credential?.mechanism else {
171-
XCTFail("Expected credential to contain authMechanism: \(testCase.description)")
172-
return
173-
}
174-
expect(actualMechanism.description.lowercased()).to(equal(expectedMechanism.lowercased()))
175-
} else if key.lowercased() == "authmechanismproperties" {
176-
// authMechanismProperties is always specified as a document in the test JSON
177-
let expectedProperties = value.documentValue!
178-
guard let actualProperties = connString.credential?.mechanismProperties else {
179-
XCTFail(
180-
"Expected credential to contain authMechanismProperties: \(testCase.description)"
181-
)
182-
return
183-
}
184-
expect(actualProperties).to(sortedEqual(expectedProperties))
185-
}
169+
expect(actualOptions[key.lowercased()]).to(sortedEqual(value))
186170
}
187171
}
188172
}

0 commit comments

Comments
 (0)