From 9ccf1870dfe1ee86386cad209346a0f89c5cccec Mon Sep 17 00:00:00 2001 From: Adelin Owona <51498470+adelinowona@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:29:40 -0400 Subject: [PATCH 1/3] CSHARP-6214: Implement srvAllowedHostsSuffix URI option (#2109) (cherry picked from commit f981df07ec1ce9f071f6520876edd59f73593c6b) --- .../connection-string/tests/invalid-uris.json | 9 + .../connection-string/tests/invalid-uris.yml | 8 + ...rvAllowedHostsSuffix-case-insensitive.json | 10 + ...srvAllowedHostsSuffix-case-insensitive.yml | 8 + .../srvAllowedHostsSuffix-mismatch.json | 6 + .../srvAllowedHostsSuffix-mismatch.yml | 5 + .../srvAllowedHostsSuffix-period-only.json | 6 + .../srvAllowedHostsSuffix-period-only.yml | 7 + .../srvAllowedHostsSuffix-tld-only.json | 6 + .../srvAllowedHostsSuffix-tld-only.yml | 6 + .../srvAllowedHostsSuffix-trailing-dot.json | 10 + .../srvAllowedHostsSuffix-trailing-dot.yml | 8 + .../srvAllowedHostsSuffix-with_dot.json | 10 + .../srvAllowedHostsSuffix-with_dot.yml | 8 + ...rvAllowedHostsSuffix-without_dot_fail.json | 6 + ...srvAllowedHostsSuffix-without_dot_fail.yml | 5 + ...rvAllowedHostsSuffix-without_dot_pass.json | 10 + ...srvAllowedHostsSuffix-without_dot_pass.yml | 6 + src/MongoDB.Driver/ClusterKey.cs | 5 + src/MongoDB.Driver/ClusterRegistry.cs | 3 +- .../Core/Clusters/DnsMonitor.cs | 36 ++-- .../Core/Clusters/DnsMonitorFactory.cs | 4 +- .../Core/Clusters/IDnsMonitorFactory.cs | 2 +- .../Core/Clusters/LoadBalancedCluster.cs | 2 +- .../Core/Clusters/MultiServerCluster.cs | 2 +- .../Core/Configuration/ClusterSettings.cs | 25 ++- .../Core/Configuration/ConnectionString.cs | 190 +++++++++++++++++- src/MongoDB.Driver/MongoClientSettings.cs | 36 ++++ src/MongoDB.Driver/MongoUrl.cs | 14 ++ src/MongoDB.Driver/MongoUrlBuilder.cs | 30 +++ tests/MongoDB.Driver.Tests/ClusterKeyTests.cs | 6 + .../ClusterRegistryTests.cs | 2 + .../Core/Clusters/DnsMonitorFactoryTests.cs | 2 +- .../Core/Clusters/DnsMonitorTests.cs | 137 +++++++------ .../Core/Clusters/LoadBalancedClusterTests.cs | 2 +- .../Core/Clusters/MultiServerClusterTests.cs | 2 +- .../Configuration/ConnectionStringTests.cs | 73 +++++++ .../MongoUrlBuilderTests.cs | 51 +++++ .../prose-tests/SrvPartsTests.cs | 31 ++- 39 files changed, 693 insertions(+), 96 deletions(-) create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-case-insensitive.json create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-case-insensitive.yml create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-mismatch.json create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-mismatch.yml create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-period-only.json create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-period-only.yml create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-tld-only.json create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-tld-only.yml create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-trailing-dot.json create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-trailing-dot.yml create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-with_dot.json create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-with_dot.yml create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_fail.json create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_fail.yml create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_pass.json create mode 100644 specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_pass.yml diff --git a/specifications/connection-string/tests/invalid-uris.json b/specifications/connection-string/tests/invalid-uris.json index a7accbd27d6..b01ca487d36 100644 --- a/specifications/connection-string/tests/invalid-uris.json +++ b/specifications/connection-string/tests/invalid-uris.json @@ -252,6 +252,15 @@ "auth": null, "options": null }, + { + "description": "srvAllowedHostsSuffix with non-SRV URI", + "uri": "mongodb://localhost:27017/?srvAllowedHostsSuffix=.mongodb.net", + "valid": false, + "warning": null, + "hosts": null, + "auth": null, + "options": null + }, { "description": "Username with password containing an unescaped percent sign", "uri": "mongodb://alice%foo:bar@127.0.0.1", diff --git a/specifications/connection-string/tests/invalid-uris.yml b/specifications/connection-string/tests/invalid-uris.yml index dd4d4ce31cd..bd9daf9c223 100644 --- a/specifications/connection-string/tests/invalid-uris.yml +++ b/specifications/connection-string/tests/invalid-uris.yml @@ -223,6 +223,14 @@ tests: hosts: ~ auth: ~ options: ~ + - + description: "srvAllowedHostsSuffix with non-SRV URI" + uri: "mongodb://localhost:27017/?srvAllowedHostsSuffix=.mongodb.net" + valid: false + warning: ~ + hosts: ~ + auth: ~ + options: ~ - description: "Username with password containing an unescaped percent sign" uri: "mongodb://alice%foo:bar@127.0.0.1" diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-case-insensitive.json b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-case-insensitive.json new file mode 100644 index 00000000000..3b1f67fec9c --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-case-insensitive.json @@ -0,0 +1,10 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.BUILD.10GEN.CC", + "seeds": [ + "localhost.build.10gen.cc:27017" + ], + "options": { + "ssl": true + }, + "ping": false +} diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-case-insensitive.yml b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-case-insensitive.yml new file mode 100644 index 00000000000..7957ba1f8c4 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-case-insensitive.yml @@ -0,0 +1,8 @@ +# Tests that srvAllowedHostsSuffix comparison is case-insensitive (ASCII case folding per RFC 4343). +# The suffix .BUILD.10GEN.CC is uppercase but should match the returned host localhost.build.10gen.cc. +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.BUILD.10GEN.CC" +seeds: + - localhost.build.10gen.cc:27017 +options: + ssl: true +ping: false diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-mismatch.json b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-mismatch.json new file mode 100644 index 00000000000..56e26524c46 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-mismatch.json @@ -0,0 +1,6 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=test.build.10gen.cc", + "seeds": [], + "hosts": [], + "error": true +} diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-mismatch.yml b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-mismatch.yml new file mode 100644 index 00000000000..db6ee9811b7 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-mismatch.yml @@ -0,0 +1,5 @@ +# DNS record for test12.test.build.10gen.cc returns localhost.build.10gen.cc which would not match test.build.10gen.cc +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=test.build.10gen.cc" +seeds: [] +hosts: [] +error: true diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-period-only.json b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-period-only.json new file mode 100644 index 00000000000..f3689428c10 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-period-only.json @@ -0,0 +1,6 @@ +{ + "uri": "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=.", + "seeds": [], + "hosts": [], + "error": true +} diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-period-only.yml b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-period-only.yml new file mode 100644 index 00000000000..bd6621e8335 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-period-only.yml @@ -0,0 +1,7 @@ +# srvAllowedHostsSuffix MUST name at least one label. A value of "." is empty once the +# leading and trailing "." are stripped, so it names no domain at all and MUST raise an +# error rather than being treated as a suffix that every host matches. +uri: "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=." +seeds: [] +hosts: [] +error: true diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-tld-only.json b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-tld-only.json new file mode 100644 index 00000000000..12098dbf5ee --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-tld-only.json @@ -0,0 +1,6 @@ +{ + "uri": "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=.cc", + "seeds": [], + "hosts": [], + "error": true +} diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-tld-only.yml b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-tld-only.yml new file mode 100644 index 00000000000..aeb9be44e63 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-tld-only.yml @@ -0,0 +1,6 @@ +# srvAllowedHostsSuffix SHOULD NOT be a public suffix. "cc" is an ordinary rule in the +# Public Suffix List, and isn't two or more `.` separated labels so `.cc` MUST raise an error. +uri: "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=.cc" +seeds: [] +hosts: [] +error: true diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-trailing-dot.json b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-trailing-dot.json new file mode 100644 index 00000000000..9befc300603 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-trailing-dot.json @@ -0,0 +1,10 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.build.10gen.cc.", + "seeds": [ + "localhost.build.10gen.cc:27017" + ], + "options": { + "ssl": true + }, + "ping": false +} diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-trailing-dot.yml b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-trailing-dot.yml new file mode 100644 index 00000000000..4da99176968 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-trailing-dot.yml @@ -0,0 +1,8 @@ +# A trailing dot in srvAllowedHostsSuffix must be stripped before comparison, +# so ".build.10gen.cc." is equivalent to ".build.10gen.cc". +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.build.10gen.cc." +seeds: + - localhost.build.10gen.cc:27017 +options: + ssl: true +ping: false diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-with_dot.json b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-with_dot.json new file mode 100644 index 00000000000..724f813918f --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-with_dot.json @@ -0,0 +1,10 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.build.10gen.cc", + "seeds": [ + "localhost.build.10gen.cc:27017" + ], + "options": { + "ssl": true + }, + "ping": false +} diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-with_dot.yml b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-with_dot.yml new file mode 100644 index 00000000000..bb73a502796 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-with_dot.yml @@ -0,0 +1,8 @@ +# Also tests trailing dot normalization: DNS returns targets with a trailing dot +# (localhost.build.10gen.cc.) which must be stripped before suffix comparison. +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.build.10gen.cc" +seeds: + - localhost.build.10gen.cc:27017 +options: + ssl: true +ping: false diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_fail.json b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_fail.json new file mode 100644 index 00000000000..b7544b66f24 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_fail.json @@ -0,0 +1,6 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=uild.10gen.cc", + "seeds": [], + "hosts": [], + "error": true +} diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_fail.yml b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_fail.yml new file mode 100644 index 00000000000..57fab7a5703 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_fail.yml @@ -0,0 +1,5 @@ +# dot should be prepended to `srvAllowedHostsSuffix` causing the host to be .uild.10gen.cc which does not match any available DNS records +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=uild.10gen.cc" +seeds: [] +hosts: [] +error: true diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_pass.json b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_pass.json new file mode 100644 index 00000000000..ea1f5c8918d --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_pass.json @@ -0,0 +1,10 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=build.10gen.cc", + "seeds": [ + "localhost.build.10gen.cc:27017" + ], + "options": { + "ssl": true + }, + "ping": false +} diff --git a/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_pass.yml b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_pass.yml new file mode 100644 index 00000000000..feca05f8c89 --- /dev/null +++ b/specifications/initial-dns-seedlist-discovery/tests/replica-set/srvAllowedHostsSuffix-without_dot_pass.yml @@ -0,0 +1,6 @@ +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=build.10gen.cc" +seeds: + - localhost.build.10gen.cc:27017 +options: + ssl: true +ping: false diff --git a/src/MongoDB.Driver/ClusterKey.cs b/src/MongoDB.Driver/ClusterKey.cs index 3be367acc51..e8d5ac0c020 100644 --- a/src/MongoDB.Driver/ClusterKey.cs +++ b/src/MongoDB.Driver/ClusterKey.cs @@ -57,6 +57,7 @@ internal sealed class ClusterKey private readonly TimeSpan _serverSelectionTimeout; private readonly TimeSpan _socketTimeout; private readonly Socks5ProxySettings _socks5ProxySettings; + private readonly string _srvAllowedHostsSuffix; private readonly int _srvMaxHosts; private readonly string _srvServiceName; private readonly SslSettings _sslSettings; @@ -97,6 +98,7 @@ public ClusterKey( TimeSpan serverSelectionTimeout, TimeSpan socketTimeout, Socks5ProxySettings socks5ProxySettings, + string srvAllowedHostsSuffix, int srvMaxHosts, string srvServiceName, SslSettings sslSettings, @@ -135,6 +137,7 @@ public ClusterKey( _serverSelectionTimeout = serverSelectionTimeout; _socketTimeout = socketTimeout; _socks5ProxySettings = socks5ProxySettings; + _srvAllowedHostsSuffix = srvAllowedHostsSuffix; _srvMaxHosts = srvMaxHosts; _srvServiceName = srvServiceName; _sslSettings = sslSettings; @@ -177,6 +180,7 @@ public ClusterKey( public TimeSpan ServerSelectionTimeout { get { return _serverSelectionTimeout; } } public TimeSpan SocketTimeout { get { return _socketTimeout; } } public Socks5ProxySettings Socks5ProxySettings { get { return _socks5ProxySettings; } } + public string SrvAllowedHostsSuffix { get { return _srvAllowedHostsSuffix; } } public int SrvMaxHosts { get { return _srvMaxHosts; } } public string SrvServiceName { get { return _srvServiceName; } } public SslSettings SslSettings { get { return _sslSettings; } } @@ -234,6 +238,7 @@ public override bool Equals(object obj) _serverSelectionTimeout == rhs._serverSelectionTimeout && _socketTimeout == rhs._socketTimeout && object.Equals(_socks5ProxySettings, rhs._socks5ProxySettings) && + _srvAllowedHostsSuffix == rhs._srvAllowedHostsSuffix && _srvMaxHosts == rhs._srvMaxHosts && _srvServiceName == rhs.SrvServiceName && object.Equals(_sslSettings, rhs._sslSettings) && diff --git a/src/MongoDB.Driver/ClusterRegistry.cs b/src/MongoDB.Driver/ClusterRegistry.cs index 473f21a5689..dbdf6789c1f 100644 --- a/src/MongoDB.Driver/ClusterRegistry.cs +++ b/src/MongoDB.Driver/ClusterRegistry.cs @@ -98,7 +98,8 @@ private ClusterSettings ConfigureCluster(ClusterSettings settings, ClusterKey cl maxServerSelectionWaitQueueSize: clusterKey.WaitQueueSize, serverApi: clusterKey.ServerApi, serverSelectionTimeout: clusterKey.ServerSelectionTimeout, - scheme: clusterKey.Scheme); + scheme: clusterKey.Scheme, + srvAllowedHostsSuffix: clusterKey.SrvAllowedHostsSuffix); } private ConnectionPoolSettings ConfigureConnectionPool(ConnectionPoolSettings settings, ClusterKey clusterKey) diff --git a/src/MongoDB.Driver/Core/Clusters/DnsMonitor.cs b/src/MongoDB.Driver/Core/Clusters/DnsMonitor.cs index 2bc36e6a0ba..42b38ee4b39 100644 --- a/src/MongoDB.Driver/Core/Clusters/DnsMonitor.cs +++ b/src/MongoDB.Driver/Core/Clusters/DnsMonitor.cs @@ -28,15 +28,6 @@ namespace MongoDB.Driver.Core.Clusters { internal class DnsMonitor : IDnsMonitor { - #region static - private static string EnsureLookupDomainNameIsValid(string lookupDomainName) - { - Ensure.IsNotNull(lookupDomainName, nameof(lookupDomainName)); - Ensure.That(lookupDomainName.Count(c => c == '.') >= 2, "LookupDomainName must have at least three components.", nameof(lookupDomainName)); - return lookupDomainName; - } - #endregion - // private fields private readonly CancellationToken _cancellationToken; private readonly IDnsMonitoringCluster _cluster; @@ -44,6 +35,7 @@ private static string EnsureLookupDomainNameIsValid(string lookupDomainName) private readonly string _lookupDomainName; private bool _processDnsResultHasEverBeenCalled; private readonly string _service; + private readonly string _srvParentDomain; private DnsMonitorState _state; private Exception _unhandledException; @@ -54,15 +46,28 @@ public DnsMonitor(IDnsMonitoringCluster cluster, IDnsResolver dnsResolver, string srvServiceName, string lookupDomainName, + string srvAllowedHostsSuffix, IEventSubscriber eventSubscriber, ILogger logger, CancellationToken cancellationToken) { _cluster = Ensure.IsNotNull(cluster, nameof(cluster)); _dnsResolver = Ensure.IsNotNull(dnsResolver, nameof(dnsResolver)); - _lookupDomainName = EnsureLookupDomainNameIsValid(lookupDomainName); + Ensure.IsNotNullOrEmpty(lookupDomainName, nameof(lookupDomainName)); + + // the domain that resolved hosts are validated against carries the same normalization + // the hosts themselves do. The query name is built from the normalized form as well, + // since DNS is case insensitive and expects A-labels on the wire. + if (!ConnectionString.TryNormalizeHostName(lookupDomainName, out _lookupDomainName)) + { + throw new MongoConfigurationException($"Unable to parse {lookupDomainName} as a hostname."); + } + _cancellationToken = cancellationToken; _service = $"_{srvServiceName}._tcp." + _lookupDomainName; + _srvParentDomain = srvAllowedHostsSuffix == null + ? null + : ConnectionString.NormalizeSrvAllowedHostsSuffix(srvAllowedHostsSuffix); _state = DnsMonitorState.Created; _eventLogger = logger.ToEventLogger(eventSubscriber); @@ -133,14 +138,15 @@ private List GetValidEndPoints(List srvRecords) foreach (var srvRecord in srvRecords) { + var endPoint = srvRecord.EndPoint; // DNS names are case-insensitive, and SDAM requires host names to be normalized to lower-case - var host = srvRecord.EndPoint.Host.ToLowerInvariant(); - if (host.EndsWith(".", StringComparison.Ordinal)) + if (!ConnectionString.TryNormalizeHostName(endPoint.Host, out var host)) { - host = host.Substring(0, host.Length - 1); + _eventLogger.LogAndPublish(new SdamInformationEvent("Unable to parse host returned by DNS SRV lookup: {0}.", endPoint.Host)); + continue; } - var endPoint = new DnsEndPoint(host, srvRecord.EndPoint.Port); + endPoint = new DnsEndPoint(host, endPoint.Port); if (IsValidHost(endPoint)) { validEndPoints.Add(endPoint); @@ -156,7 +162,7 @@ private List GetValidEndPoints(List srvRecords) private bool IsValidHost(DnsEndPoint endPoint) { - return ConnectionString.HasValidParentDomain(_lookupDomainName, endPoint); + return ConnectionString.HasValidParentDomain(_lookupDomainName, endPoint, _srvParentDomain); } private void Monitor() diff --git a/src/MongoDB.Driver/Core/Clusters/DnsMonitorFactory.cs b/src/MongoDB.Driver/Core/Clusters/DnsMonitorFactory.cs index b242bca8974..0158b4cf10b 100644 --- a/src/MongoDB.Driver/Core/Clusters/DnsMonitorFactory.cs +++ b/src/MongoDB.Driver/Core/Clusters/DnsMonitorFactory.cs @@ -32,10 +32,10 @@ public DnsMonitorFactory(IEventSubscriber eventSubscriber, ILoggerFactory logger _loggerFactory = loggerFactory; } - public IDnsMonitor CreateDnsMonitor(IDnsMonitoringCluster cluster, string srvServiceName, string lookupDomainName, CancellationToken cancellationToken) + public IDnsMonitor CreateDnsMonitor(IDnsMonitoringCluster cluster, string srvServiceName, string lookupDomainName, string srvAllowedHostsSuffix, CancellationToken cancellationToken) { var dnsResolver = DnsClientWrapper.Instance; - return new DnsMonitor(cluster, dnsResolver, srvServiceName, lookupDomainName, _eventSubscriber, _loggerFactory?.CreateLogger(), cancellationToken); + return new DnsMonitor(cluster, dnsResolver, srvServiceName, lookupDomainName, srvAllowedHostsSuffix, _eventSubscriber, _loggerFactory?.CreateLogger(), cancellationToken); } } } diff --git a/src/MongoDB.Driver/Core/Clusters/IDnsMonitorFactory.cs b/src/MongoDB.Driver/Core/Clusters/IDnsMonitorFactory.cs index adcebce3862..17bd70f861a 100644 --- a/src/MongoDB.Driver/Core/Clusters/IDnsMonitorFactory.cs +++ b/src/MongoDB.Driver/Core/Clusters/IDnsMonitorFactory.cs @@ -19,6 +19,6 @@ namespace MongoDB.Driver.Core.Clusters { internal interface IDnsMonitorFactory { - IDnsMonitor CreateDnsMonitor(IDnsMonitoringCluster cluster, string srvServiceName, string lookupDomainName, CancellationToken cancellationToken); + IDnsMonitor CreateDnsMonitor(IDnsMonitoringCluster cluster, string srvServiceName, string lookupDomainName, string srvAllowedHostsSuffix, CancellationToken cancellationToken); } } diff --git a/src/MongoDB.Driver/Core/Clusters/LoadBalancedCluster.cs b/src/MongoDB.Driver/Core/Clusters/LoadBalancedCluster.cs index 8440e8e9bed..385f3abba0f 100644 --- a/src/MongoDB.Driver/Core/Clusters/LoadBalancedCluster.cs +++ b/src/MongoDB.Driver/Core/Clusters/LoadBalancedCluster.cs @@ -188,7 +188,7 @@ public void Initialize() // _server will be created after srv resolving var dnsEndPoint = (DnsEndPoint)endPoint; var lookupDomainName = dnsEndPoint.Host; - var monitor = _dnsMonitorFactory.CreateDnsMonitor(this, _settings.SrvServiceName, lookupDomainName, _dnsMonitorCancellationTokenSource.Token); + var monitor = _dnsMonitorFactory.CreateDnsMonitor(this, _settings.SrvServiceName, lookupDomainName, _settings.SrvAllowedHostsSuffix, _dnsMonitorCancellationTokenSource.Token); _dnsMonitorThread = monitor.Start(); } diff --git a/src/MongoDB.Driver/Core/Clusters/MultiServerCluster.cs b/src/MongoDB.Driver/Core/Clusters/MultiServerCluster.cs index 8fd9dc8a451..59a454f2026 100644 --- a/src/MongoDB.Driver/Core/Clusters/MultiServerCluster.cs +++ b/src/MongoDB.Driver/Core/Clusters/MultiServerCluster.cs @@ -141,7 +141,7 @@ public override void Initialize() { var dnsEndPoint = (DnsEndPoint)Settings.EndPoints.Single(); var lookupDomainName = dnsEndPoint.Host; - var dnsMonitor = _dnsMonitorFactory.CreateDnsMonitor(this, Settings.SrvServiceName, lookupDomainName, _monitorServersCancellationTokenSource.Token); + var dnsMonitor = _dnsMonitorFactory.CreateDnsMonitor(this, Settings.SrvServiceName, lookupDomainName, Settings.SrvAllowedHostsSuffix, _monitorServersCancellationTokenSource.Token); _dnsMonitorThread = dnsMonitor.Start(); // store the Thread for use as evidence when testing that the DnsMonitor was started } } diff --git a/src/MongoDB.Driver/Core/Configuration/ClusterSettings.cs b/src/MongoDB.Driver/Core/Configuration/ClusterSettings.cs index 243b748b9db..a73b72e4c76 100644 --- a/src/MongoDB.Driver/Core/Configuration/ClusterSettings.cs +++ b/src/MongoDB.Driver/Core/Configuration/ClusterSettings.cs @@ -44,6 +44,7 @@ public class ClusterSettings private readonly ConnectionStringScheme _scheme; private readonly ServerApi _serverApi; private readonly TimeSpan _serverSelectionTimeout; + private readonly string _srvAllowedHostsSuffix; private readonly int _srvMaxHosts; private readonly string _srvServiceName; private readonly IServerSelector _preServerSelector; @@ -67,6 +68,7 @@ public class ClusterSettings /// The connection string scheme. /// Limits the number of SRV records used to populate the seedlist during initial discovery, as well as the number of additional hosts that may be added during SRV polling. /// The SRV service name which modifies the srv URI to look like: _{srvServiceName}._tcp.{hostname}.{domainname} Defaults to "mongodb". + /// The hostname suffix that hosts returned by an SRV lookup are validated against, replacing the domain name that would otherwise be inferred from the SRV hostname. WARNING: Modifying the default SRV domain name validation can create vulnerabilities. public ClusterSettings( Optional cryptClientSettings = default, Optional directConnection = default, @@ -81,7 +83,8 @@ public ClusterSettings( Optional postServerSelector = default(Optional), Optional scheme = default(Optional), Optional srvMaxHosts = default, - Optional srvServiceName = default(Optional)) + Optional srvServiceName = default(Optional), + Optional srvAllowedHostsSuffix = default(Optional)) { _cryptClientSettings = cryptClientSettings.WithDefault(null); _directConnection = directConnection.WithDefault(false); @@ -97,6 +100,7 @@ public ClusterSettings( _scheme = scheme.WithDefault(ConnectionStringScheme.MongoDB); _srvMaxHosts = Ensure.IsGreaterThanOrEqualToZero(srvMaxHosts.WithDefault(0), nameof(srvMaxHosts)); _srvServiceName = srvServiceName.WithDefault(MongoInternalDefaults.MongoClientSettings.SrvServiceName); + _srvAllowedHostsSuffix = srvAllowedHostsSuffix.WithDefault(null); } // properties @@ -204,6 +208,18 @@ public TimeSpan ServerSelectionTimeout get { return _serverSelectionTimeout; } } + /// + /// Gets the hostname suffix that hosts returned by an SRV lookup are validated against. + /// When set, it replaces the domain name that would otherwise be inferred from the SRV + /// hostname. + /// + /// WARNING: Modifying the default SRV domain name validation can create vulnerabilities. + /// Prefer the narrowest suffix that covers the deployment: the broader it is, the more + /// hosts a forged SRV response could direct the driver to. + /// + /// + public string SrvAllowedHostsSuffix => _srvAllowedHostsSuffix; + /// /// Limits the number of SRV records used to populate the seedlist /// during initial discovery, as well as the number of additional hosts @@ -258,6 +274,7 @@ public IServerSelector PostServerSelector /// The connection string scheme. /// Limits the number of SRV records used to populate the seedlist during initial discovery, as well as the number of additional hosts that may be added during SRV polling. /// The SRV service name which modifies the srv URI to look like: _{srvServiceName}._tcp.{hostname}.{domainname} Defaults to "mongodb". + /// The hostname suffix that hosts returned by an SRV lookup are validated against, replacing the domain name that would otherwise be inferred from the SRV hostname. WARNING: Modifying the default SRV domain name validation can create vulnerabilities. /// A new ClusterSettings instance. public ClusterSettings With( Optional cryptClientSettings = default, @@ -273,7 +290,8 @@ public ClusterSettings With( Optional postServerSelector = default(Optional), Optional scheme = default(Optional), Optional srvMaxHosts = default, - Optional srvServiceName = default(Optional)) + Optional srvServiceName = default(Optional), + Optional srvAllowedHostsSuffix = default(Optional)) { return new ClusterSettings( cryptClientSettings: cryptClientSettings.WithDefault(_cryptClientSettings), @@ -289,7 +307,8 @@ public ClusterSettings With( postServerSelector: Optional.Create(postServerSelector.WithDefault(_postServerSelector)), scheme: scheme.WithDefault(_scheme), srvMaxHosts: srvMaxHosts.WithDefault(_srvMaxHosts), - srvServiceName: srvServiceName.WithDefault(_srvServiceName)); + srvServiceName: srvServiceName.WithDefault(_srvServiceName), + srvAllowedHostsSuffix: srvAllowedHostsSuffix.WithDefault(_srvAllowedHostsSuffix)); } // internal methods diff --git a/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs b/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs index e7393008230..141547aaf2b 100644 --- a/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs +++ b/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Globalization; using System.Linq; using System.Net; using System.Text.RegularExpressions; @@ -52,6 +53,22 @@ public sealed class ConnectionString private const int defaultMongoDBPort = 27017; private const int defaultSrvPort = 53; + // private static fields + // the single labels that srvAllowedHostsSuffix may name, per the specification. They name + // private or special-use namespaces rather than a public registry, so allowing every host + // under one of them does not allow every host under a public suffix. + private static readonly string[] __validSingleLabelSrvAllowedHostsSuffixes = new[] + { + // RFC 6761 special-use names + "test", "localhost", "invalid", "example", + // RFC 6762 multicast DNS + "local", + // reserved by ICANN for private use + "internal", + // not reserved by ICANN, but commonly used privately + "corp", "home", "mail" + }; + // private fields private readonly string _originalConnectionString; private readonly NameValueCollection _allOptions; @@ -102,7 +119,9 @@ public sealed class ConnectionString private ServerMonitoringMode? _serverMonitoringMode; private TimeSpan? _serverSelectionTimeout; private TimeSpan? _socketTimeout; + private string _srvAllowedHostsSuffix; private int? _srvMaxHosts; + private string _srvParentDomain; private string _srvServiceName; #pragma warning disable CS0649 // Field is never assigned to, and will always have its default value private TimeSpan? _timeout; @@ -486,6 +505,18 @@ public TimeSpan? SocketTimeout get { return _socketTimeout; } } + /// + /// Gets the hostname suffix that hosts returned by an SRV lookup are validated against, + /// as it was specified in the connection string. When set, it replaces the domain name + /// that would otherwise be inferred from the SRV hostname. + /// + /// WARNING: Modifying the default SRV domain name validation can create vulnerabilities. + /// Prefer the narrowest suffix that covers the deployment: the broader it is, the more + /// hosts a forged SRV response could direct the driver to. + /// + /// + public string SrvAllowedHostsSuffix => _srvAllowedHostsSuffix; + /// /// Limits the number of SRV records used to populate the seedlist /// during initial discovery, as well as the number of additional hosts @@ -932,6 +963,16 @@ private void Parse() throw new MongoConfigurationException("Specifying srvServiceName is only allowed with the mongodb+srv scheme."); } + if (_srvAllowedHostsSuffix != null) + { + if (!_isInternalRepresentation && _scheme != ConnectionStringScheme.MongoDBPlusSrv) + { + throw new MongoConfigurationException("Specifying srvAllowedHostsSuffix is only allowed with the mongodb+srv scheme."); + } + + _srvParentDomain = NormalizeSrvAllowedHostsSuffix(_srvAllowedHostsSuffix); + } + if (_loadBalanced) { if (_hosts.Count > 1) @@ -1201,6 +1242,9 @@ private void ParseOption(string name, string value) case "sockettimeoutms": _socketTimeout = ParseTimeSpan(name, value); break; + case "srvallowedhostssuffix": + _srvAllowedHostsSuffix = value; + break; case "srvmaxhosts": var srvMaxHostsValue = ParseInt32(name, value); if (srvMaxHostsValue < 0) @@ -1275,6 +1319,120 @@ private void ParseOption(string name, string value) } // private static methods + + internal static string NormalizeSrvAllowedHostsSuffix(string value) + { + if (!TryNormalizeSrvAllowedHostsSuffix(value, out var suffix, out var errorMessage)) + { + throw new MongoConfigurationException(errorMessage); + } + + return suffix; + } + + // The steps and their order are mandated by the specification. + internal static bool TryNormalizeSrvAllowedHostsSuffix(string value, out string normalizedSuffix, out string errorMessage) + { + normalizedSuffix = null; + errorMessage = null; + + // IdnMapping rejects an empty label, so the dots have to come off before the Punycode + // step rather than after it + var suffix = value.Trim('.'); + if (suffix.Length == 0) + { + errorMessage = FormatSrvAllowedHostsSuffixLabelsMessage(value); + return false; + } + + suffix = TryGetAsciiForm(suffix, out var errorDetail); + if (suffix == null) + { + errorMessage = $"srvAllowedHostsSuffix \"{value}\" is not a valid domain name: {errorDetail}"; + return false; + } + + if (suffix.IndexOf('.') < 0 && Array.IndexOf(__validSingleLabelSrvAllowedHostsSuffixes, suffix) < 0) + { + errorMessage = FormatSrvAllowedHostsSuffixLabelsMessage(value); + return false; + } + + normalizedSuffix = "." + suffix; + return true; + } + + private static string FormatSrvAllowedHostsSuffixLabelsMessage(string value) => + $"srvAllowedHostsSuffix \"{value}\" must name at least two domain labels, or one of these single " + + $"labels: {string.Join(", ", __validSingleLabelSrvAllowedHostsSuffixes)}. Any other single label " + + "would allow any host registered under it. Specify a suffix that names the deployment's own domain."; + + // The specification requires a hostname returned by an SRV lookup, and the domain it is + // validated against, to carry the same normalization, so that neither trailing dots, case, + // nor Unicode/Punycode encoding can affect the comparison. The steps and their order are + // mandated by the specification. A hostname with no A-label form is one the driver can + // neither validate nor connect to, so it is reported as invalid rather than compared as-is. + internal static bool TryNormalizeHostName(string host, out string normalizedHost) + { + normalizedHost = null; + + if (host.EndsWith(".", StringComparison.Ordinal)) + { + host = host.Substring(0, host.Length - 1); + } + + if (host.Length == 0) + { + return false; + } + + normalizedHost = TryGetAsciiForm(host, out _); + return normalizedHost != null; + } + + // Returns the lowercased A-label form of value, or null when value has none. errorDetail + // carries the framework's explanation, which names the offending character or length. + private static string TryGetAsciiForm(string value, out string errorDetail) + { + errorDetail = null; + + string ascii; + try + { + ascii = new IdnMapping().GetAscii(value).ToLowerInvariant(); + } + catch (ArgumentException exception) + { + errorDetail = exception.Message; + return null; + } + +#if NETFRAMEWORK + // .NET Core rejects a label that starts with "xn--" and does not decode as Punycode, + // but .NET Framework returns all-ASCII input unchanged without decoding it. Decoding + // the A-labels here keeps the validation identical on every target framework. + foreach (var label in ascii.Split('.')) + { + if (label.StartsWith("xn--", StringComparison.Ordinal)) + { + try + { + new IdnMapping().GetUnicode(label); + } + catch (ArgumentException) + { + // GetUnicode was passed a single label, so its own message would describe + // that label in isolation and name a parameter the caller never supplied + errorDetail = $"\"{label}\" is not valid Punycode."; + return null; + } + } + } +#endif + + return ascii; + } + private static IEnumerable> GetAuthMechanismProperties(string name, string value) { foreach (var property in value.Split(',')) @@ -1443,11 +1601,11 @@ private List GetHostsFromSrvRecords(IEnumerable srvRecords) foreach (var srvRecord in srvRecords) { var h = srvRecord.EndPoint.Host; - if (h.EndsWith(".", StringComparison.Ordinal)) + if (!TryNormalizeHostName(h, out var normalizedHost)) { - h = h.Substring(0, h.Length - 1); + throw new MongoConfigurationException($"Unable to parse {h} as a hostname."); } - hosts.Add(h + ":" + srvRecord.EndPoint.Port); + hosts.Add(normalizedHost + ":" + srvRecord.EndPoint.Port); } if (_srvMaxHosts > 0) @@ -1485,6 +1643,15 @@ private void ValidateResolvedHosts(string original, List resolved) throw new MongoConfigurationException($"No hosts were found in the SRV record for {original}."); } + // the domain that resolved hosts are validated against carries the same normalization + // the hosts themselves do. A configured suffix was normalized when it was parsed, so + // only the domain inferred from the seed host is normalized here. + var lookupDomainName = original; + if (_srvParentDomain == null && !TryNormalizeHostName(original, out lookupDomainName)) + { + throw new MongoConfigurationException($"Unable to parse {original} as a hostname."); + } + // for each resolved host, make sure that it ends with domain of the parent. foreach (var resolvedHost in resolved) { @@ -1495,18 +1662,27 @@ private void ValidateResolvedHosts(string original, List resolved) } var dnsEndPoint = (DnsEndPoint)endPoint; - var host = ((DnsEndPoint)endPoint).Host; - if (!HasValidParentDomain(original, dnsEndPoint)) + if (!HasValidParentDomain(lookupDomainName, dnsEndPoint, _srvParentDomain)) { - throw new MongoConfigurationException($"Hosts in the SRV record must have the same parent domain as the seed host."); + throw new MongoConfigurationException(_srvParentDomain == null + ? "Hosts in the SRV record must have the same parent domain as the seed host." + : $"Hosts in the SRV record must end with the srvAllowedHostsSuffix \"{_srvParentDomain}\"."); } } } - internal static bool HasValidParentDomain(string original, DnsEndPoint resolvedEndPoint) + internal static bool HasValidParentDomain(string original, DnsEndPoint resolvedEndPoint, string srvParentDomain) { var host = resolvedEndPoint.Host; + // a configured suffix states the parent domain outright, so it replaces both the + // domain inferred from the seed host and the extra domain level that a seed with + // fewer than three parts would otherwise require + if (srvParentDomain != null) + { + return host.EndsWith(srvParentDomain, StringComparison.Ordinal); + } + var hostDotCount = host.Count(c => c == '.'); var originalDotCount = original.Count(c => c == '.'); diff --git a/src/MongoDB.Driver/MongoClientSettings.cs b/src/MongoDB.Driver/MongoClientSettings.cs index 187b63e0d7d..ed63ce3aecd 100644 --- a/src/MongoDB.Driver/MongoClientSettings.cs +++ b/src/MongoDB.Driver/MongoClientSettings.cs @@ -74,6 +74,7 @@ public class MongoClientSettings : IEquatable, IInheritable private TimeSpan _serverSelectionTimeout; private TimeSpan _socketTimeout; private Socks5ProxySettings _socks5ProxySettings; + private string _srvAllowedHostsSuffix; private int _srvMaxHosts; private string _srvServiceName; private SslSettings _sslSettings; @@ -686,6 +687,30 @@ public TimeSpan SocketTimeout } } + /// + /// Gets or sets the hostname suffix that hosts returned by an SRV lookup are validated + /// against. When set, it replaces the domain name that would otherwise be inferred from + /// the SRV hostname. + /// + /// WARNING: Modifying the default SRV domain name validation can create vulnerabilities. + /// Prefer the narrowest suffix that covers the deployment: the broader it is, the more + /// hosts a forged SRV response could direct the driver to. + /// + /// + public string SrvAllowedHostsSuffix + { + get { return _srvAllowedHostsSuffix; } + set + { + if (_isFrozen) { throw new InvalidOperationException("MongoClientSettings is frozen."); } + if (value != null && !ConnectionString.TryNormalizeSrvAllowedHostsSuffix(value, out _, out var errorMessage)) + { + throw new ArgumentException(errorMessage, nameof(SrvAllowedHostsSuffix)); + } + _srvAllowedHostsSuffix = value; + } + } + /// /// Limits the number of SRV records used to populate the seedlist /// during initial discovery, as well as the number of additional hosts @@ -958,6 +983,7 @@ public static MongoClientSettings FromUrl(MongoUrl url) { clientSettings.Socks5ProxySettings = Socks5ProxySettings.Create(url.ProxyHost, url.ProxyPort, url.ProxyUsername, url.ProxyPassword); } + clientSettings.SrvAllowedHostsSuffix = url.SrvAllowedHostsSuffix; clientSettings.SrvMaxHosts = url.SrvMaxHosts.GetValueOrDefault(0); clientSettings.SrvServiceName = url.SrvServiceName; clientSettings.SslSettings = null; @@ -1021,6 +1047,7 @@ public MongoClientSettings Clone() clone._serverSelectionTimeout = _serverSelectionTimeout; clone._socketTimeout = _socketTimeout; clone._socks5ProxySettings = _socks5ProxySettings; + clone._srvAllowedHostsSuffix = _srvAllowedHostsSuffix; clone._srvMaxHosts = _srvMaxHosts; clone._srvServiceName = _srvServiceName; clone._sslSettings = (_sslSettings == null) ? null : _sslSettings.Clone(); @@ -1095,6 +1122,7 @@ public override bool Equals(object obj) _serverSelectionTimeout == rhs._serverSelectionTimeout && _socketTimeout == rhs._socketTimeout && object.Equals(_socks5ProxySettings, rhs._socks5ProxySettings) && + _srvAllowedHostsSuffix == rhs._srvAllowedHostsSuffix && _srvMaxHosts == rhs._srvMaxHosts && _srvServiceName == rhs._srvServiceName && _sslSettings == rhs._sslSettings && @@ -1186,6 +1214,7 @@ public override int GetHashCode() .Hash(_serverSelectionTimeout) .Hash(_socketTimeout) .Hash(_socks5ProxySettings) + .Hash(_srvAllowedHostsSuffix) .Hash(_srvMaxHosts) .Hash(_srvServiceName) .Hash(_sslSettings) @@ -1270,6 +1299,7 @@ public override string ToString() sb.AppendFormat("serverMonitoringMode={0};", _serverMonitoringMode); sb.AppendFormat("ServerSelectionTimeout={0};", _serverSelectionTimeout); sb.AppendFormat("SocketTimeout={0};", _socketTimeout); + sb.AppendFormat("SrvAllowedHostsSuffix={0};", _srvAllowedHostsSuffix); sb.AppendFormat("SrvMaxHosts={0};", _srvMaxHosts); sb.AppendFormat("SrvServiceName={0};", _srvServiceName); if (_sslSettings != null) @@ -1330,6 +1360,7 @@ internal ClusterKey ToClusterKey() _serverSelectionTimeout, _socketTimeout, _socks5ProxySettings, + _srvAllowedHostsSuffix, _srvMaxHosts, _srvServiceName, _sslSettings, @@ -1385,6 +1416,11 @@ private void ThrowIfSettingsAreInvalid() throw new InvalidOperationException("Specifying srvServiceName is only allowed with the mongodb+srv scheme."); } + if (_srvAllowedHostsSuffix != null && _scheme != ConnectionStringScheme.MongoDBPlusSrv) + { + throw new InvalidOperationException("Specifying srvAllowedHostsSuffix is only allowed with the mongodb+srv scheme."); + } + if (_loadBalanced) { if (_servers.Count > 1) diff --git a/src/MongoDB.Driver/MongoUrl.cs b/src/MongoDB.Driver/MongoUrl.cs index a74cec4c4fc..3833fdfaf72 100644 --- a/src/MongoDB.Driver/MongoUrl.cs +++ b/src/MongoDB.Driver/MongoUrl.cs @@ -75,6 +75,7 @@ public class MongoUrl : IEquatable private readonly ServerMonitoringMode? _serverMonitoringMode; private readonly TimeSpan _serverSelectionTimeout; private readonly TimeSpan _socketTimeout; + private readonly string _srvAllowedHostsSuffix; private readonly int? _srvMaxHosts; private readonly string _srvServiceName; private readonly TimeSpan? _timeout; @@ -140,6 +141,7 @@ internal MongoUrl(MongoUrlBuilder builder) _serverMonitoringMode = builder.ServerMonitoringMode; _serverSelectionTimeout = builder.ServerSelectionTimeout; _socketTimeout = builder.SocketTimeout; + _srvAllowedHostsSuffix = builder.SrvAllowedHostsSuffix; _srvMaxHosts = builder.SrvMaxHosts; _srvServiceName = builder.SrvServiceName; _timeout = builder.Timeout; @@ -492,6 +494,18 @@ public TimeSpan SocketTimeout get { return _socketTimeout; } } + /// + /// Gets the hostname suffix that hosts returned by an SRV lookup are validated against. + /// When set, it replaces the domain name that would otherwise be inferred from the SRV + /// hostname. + /// + /// WARNING: Modifying the default SRV domain name validation can create vulnerabilities. + /// Prefer the narrowest suffix that covers the deployment: the broader it is, the more + /// hosts a forged SRV response could direct the driver to. + /// + /// + public string SrvAllowedHostsSuffix => _srvAllowedHostsSuffix; + /// /// Limits the number of SRV records used to populate the seedlist /// during initial discovery, as well as the number of additional hosts diff --git a/src/MongoDB.Driver/MongoUrlBuilder.cs b/src/MongoDB.Driver/MongoUrlBuilder.cs index da692463bd0..9d363f79d1a 100644 --- a/src/MongoDB.Driver/MongoUrlBuilder.cs +++ b/src/MongoDB.Driver/MongoUrlBuilder.cs @@ -70,6 +70,7 @@ public class MongoUrlBuilder private ServerMonitoringMode? _serverMonitoringMode; private TimeSpan _serverSelectionTimeout; private TimeSpan _socketTimeout; + private string _srvAllowedHostsSuffix; private int? _srvMaxHosts; private string _srvServiceName; private TimeSpan? _timeout; @@ -126,6 +127,7 @@ public MongoUrlBuilder() _serverMonitoringMode = null; _serverSelectionTimeout = MongoDefaults.ServerSelectionTimeout; _socketTimeout = MongoDefaults.SocketTimeout; + _srvAllowedHostsSuffix = null; _srvMaxHosts = null; _srvServiceName = MongoInternalDefaults.MongoClientSettings.SrvServiceName; _timeout = null; @@ -643,6 +645,29 @@ public TimeSpan SocketTimeout } } + /// + /// Gets or sets the hostname suffix that hosts returned by an SRV lookup are validated + /// against. When set, it replaces the domain name that would otherwise be inferred from + /// the SRV hostname. + /// + /// WARNING: Modifying the default SRV domain name validation can create vulnerabilities. + /// Prefer the narrowest suffix that covers the deployment: the broader it is, the more + /// hosts a forged SRV response could direct the driver to. + /// + /// + public string SrvAllowedHostsSuffix + { + get { return _srvAllowedHostsSuffix; } + set + { + if (value != null && !ConnectionString.TryNormalizeSrvAllowedHostsSuffix(value, out _, out var errorMessage)) + { + throw new ArgumentException(errorMessage, nameof(SrvAllowedHostsSuffix)); + } + _srvAllowedHostsSuffix = value; + } + } + /// /// Limits the number of SRV records used to populate the seedlist /// during initial discovery, as well as the number of additional hosts @@ -1111,6 +1136,10 @@ public override string ToString() { query.AppendFormat("srvServiceName={0}&", Uri.EscapeDataString(_srvServiceName)); } + if (_srvAllowedHostsSuffix != null) + { + query.AppendFormat("srvAllowedHostsSuffix={0}&", Uri.EscapeDataString(_srvAllowedHostsSuffix)); + } if (query.Length != 0) { query.Length = query.Length - 1; // remove trailing "&" @@ -1172,6 +1201,7 @@ private void InitializeFromConnectionString(ConnectionString connectionString) _serverMonitoringMode = connectionString.ServerMonitoringMode; _serverSelectionTimeout = connectionString.ServerSelectionTimeout.GetValueOrDefault(MongoDefaults.ServerSelectionTimeout); _socketTimeout = connectionString.SocketTimeout.GetValueOrDefault(MongoDefaults.SocketTimeout); + _srvAllowedHostsSuffix = connectionString.SrvAllowedHostsSuffix; _srvMaxHosts = connectionString.SrvMaxHosts; _srvServiceName = connectionString.SrvServiceName ?? MongoInternalDefaults.MongoClientSettings.SrvServiceName; _timeout = connectionString.Timeout; diff --git a/tests/MongoDB.Driver.Tests/ClusterKeyTests.cs b/tests/MongoDB.Driver.Tests/ClusterKeyTests.cs index 7569431c76a..597607b65ca 100644 --- a/tests/MongoDB.Driver.Tests/ClusterKeyTests.cs +++ b/tests/MongoDB.Driver.Tests/ClusterKeyTests.cs @@ -76,6 +76,7 @@ public void Equals_should_return_true_if_all_fields_are_equal() [InlineData("ServerSelectionTimeout", true)] [InlineData("SocketTimeout", true)] [InlineData("Socks5ProxySettings", true)] + [InlineData("SrvAllowedHostsSuffix", true)] [InlineData("SrvMaxHosts", true)] [InlineData("SslSettings", true)] [InlineData("TracingOptions", true)] @@ -184,6 +185,7 @@ private ClusterKey CreateSubject(string notEqualFieldName = null) var serverSelectionTimeout = TimeSpan.FromSeconds(6); var socketTimeout = TimeSpan.FromSeconds(4); var socks5ProxySettings = Socks5ProxySettings.Create("localhost", 1080, "user", "password"); + string srvAllowedHostsSuffix = null; var srvMaxHosts = 0; var srvServiceName = "mongodb"; var sslSettings = new SslSettings @@ -234,6 +236,7 @@ private ClusterKey CreateSubject(string notEqualFieldName = null) case "ServerSelectionTimeout": serverSelectionTimeout = TimeSpan.FromSeconds(98); break; case "SocketTimeout": socketTimeout = TimeSpan.FromSeconds(99); break; case "Socks5ProxySettings": socks5ProxySettings = Socks5ProxySettings.Create("different", 1080, "user", "password"); break; + case "SrvAllowedHostsSuffix": srvAllowedHostsSuffix = ".mydomain.net"; break; case "SrvMaxHosts": srvMaxHosts = 3; break; case "SrvServiceName": srvServiceName = "customname"; break; case "SslSettings": sslSettings.CheckCertificateRevocation = !sslSettings.CheckCertificateRevocation; break; @@ -276,6 +279,7 @@ private ClusterKey CreateSubject(string notEqualFieldName = null) serverSelectionTimeout, socketTimeout, socks5ProxySettings, + srvAllowedHostsSuffix, srvMaxHosts, srvServiceName, sslSettings, @@ -322,6 +326,7 @@ internal ClusterKey CreateSubjectWith( var serverSelectionTimeout = TimeSpan.FromSeconds(6); var socketTimeout = TimeSpan.FromSeconds(4); var socks5ProxySettings = Socks5ProxySettings.Create("localhost", 1080, "user", "password"); + var srvAllowedHostsSuffix = ".mydomain.net"; var srvMaxHosts = 3; var srvServiceName = "customname"; var sslSettings = new SslSettings @@ -365,6 +370,7 @@ internal ClusterKey CreateSubjectWith( serverSelectionTimeout, socketTimeout, socks5ProxySettings, + srvAllowedHostsSuffix, srvMaxHosts, srvServiceName, sslSettings, diff --git a/tests/MongoDB.Driver.Tests/ClusterRegistryTests.cs b/tests/MongoDB.Driver.Tests/ClusterRegistryTests.cs index 4d649184a6d..f74de30158d 100644 --- a/tests/MongoDB.Driver.Tests/ClusterRegistryTests.cs +++ b/tests/MongoDB.Driver.Tests/ClusterRegistryTests.cs @@ -142,6 +142,7 @@ public void GetOrCreateCluster_should_return_a_cluster_with_the_correct_settings serverSelectionTimeout: TimeSpan.FromSeconds(11), socketTimeout: TimeSpan.FromSeconds(12), socks5ProxySettings: null, + srvAllowedHostsSuffix: ".mydomain.net", srvMaxHosts: 0, srvServiceName: "mongodb", sslSettings: sslSettings, @@ -170,6 +171,7 @@ public void GetOrCreateCluster_should_return_a_cluster_with_the_correct_settings cluster.Settings.Scheme.Should().Be(clusterKey.Scheme); cluster.Settings.ServerApi.Should().Be(clusterKey.ServerApi); cluster.Settings.ServerSelectionTimeout.Should().Be(clusterKey.ServerSelectionTimeout); + cluster.Settings.SrvAllowedHostsSuffix.Should().Be(clusterKey.SrvAllowedHostsSuffix); cluster.Description.Servers.Select(s => s.EndPoint).Should().BeEquivalentTo(expectedEndPoints); diff --git a/tests/MongoDB.Driver.Tests/Core/Clusters/DnsMonitorFactoryTests.cs b/tests/MongoDB.Driver.Tests/Core/Clusters/DnsMonitorFactoryTests.cs index eeff3e46cf3..b173e449b72 100644 --- a/tests/MongoDB.Driver.Tests/Core/Clusters/DnsMonitorFactoryTests.cs +++ b/tests/MongoDB.Driver.Tests/Core/Clusters/DnsMonitorFactoryTests.cs @@ -56,7 +56,7 @@ public void CreateDnsMonitor_should_return_expected_result() using var cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = cancellationTokenSource.Token; - var result = subject.CreateDnsMonitor(cluster, "mongodb", lookupDomainName, cancellationToken); + var result = subject.CreateDnsMonitor(cluster, "mongodb", lookupDomainName, null, cancellationToken); var dnsMonitor = result.Should().BeOfType().Subject; dnsMonitor._cluster().Should().BeSameAs(cluster); diff --git a/tests/MongoDB.Driver.Tests/Core/Clusters/DnsMonitorTests.cs b/tests/MongoDB.Driver.Tests/Core/Clusters/DnsMonitorTests.cs index c7f29cb0a6a..3ce095ac0b5 100644 --- a/tests/MongoDB.Driver.Tests/Core/Clusters/DnsMonitorTests.cs +++ b/tests/MongoDB.Driver.Tests/Core/Clusters/DnsMonitorTests.cs @@ -30,37 +30,6 @@ namespace MongoDB.Driver.Core.Clusters { public class DnsMonitorTests { - [Theory] - [InlineData("a.b.com")] - [InlineData("a.b.c.com")] - public void EnsureLookupDomainNameIsValid_should_return_expected_result(string lookupDomainName) - { - var result = DnsMonitorReflector.EnsureLookupDomainNameIsValid(lookupDomainName); - - result.Should().Be(lookupDomainName); - } - - [Fact] - public void EnsureLookupDomainNameIsValid_should_throw_when_lookupDomainName_is_null() - { - var exception = Record.Exception(() => DnsMonitorReflector.EnsureLookupDomainNameIsValid(null)); - - var e = exception.Should().BeOfType().Subject; - e.ParamName.Should().Be("lookupDomainName"); - } - - [Theory] - [InlineData("")] - [InlineData("com")] - [InlineData("a.com")] - public void EnsureLookupDomainNameIsValid_should_throw_when_lookupDomainName_is_invalid(string lookupDomainName) - { - var exception = Record.Exception(() => DnsMonitorReflector.EnsureLookupDomainNameIsValid(lookupDomainName)); - - var e = exception.Should().BeOfType().Subject; - e.ParamName.Should().Be("lookupDomainName"); - } - [Fact] public void constructor_should_initialize_instance() { @@ -75,7 +44,7 @@ public void constructor_should_initialize_instance() using var cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = cancellationTokenSource.Token; - var subject = new DnsMonitor(cluster, dnsResolver, "mongodb", lookupDomainName, mockEventSubscriber.Object, null, cancellationToken); + var subject = new DnsMonitor(cluster, dnsResolver, "mongodb", lookupDomainName, null, mockEventSubscriber.Object, null, cancellationToken); subject.State.Should().Be(DnsMonitorState.Created); subject._cancellationToken().Should().Be(cancellationToken); @@ -95,7 +64,7 @@ public void constructor_should_throw_when_cluster_is_null() using var cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = cancellationTokenSource.Token; - var exception = Record.Exception(() => new DnsMonitor(null, dnsResolver, "mongodb", lookupDomainName, null, null, cancellationToken)); + var exception = Record.Exception(() => new DnsMonitor(null, dnsResolver, "mongodb", lookupDomainName, null, null, null, cancellationToken)); var e = exception.Should().BeOfType().Subject; e.ParamName.Should().Be("cluster"); @@ -109,7 +78,7 @@ public void constructor_should_throw_when_dnsResolver_is_null() using var cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = cancellationTokenSource.Token; - var exception = Record.Exception(() => new DnsMonitor(cluster, null, "mongodb", lookupDomainName, null, null, cancellationToken)); + var exception = Record.Exception(() => new DnsMonitor(cluster, null, "mongodb", lookupDomainName, null, null, null, cancellationToken)); var e = exception.Should().BeOfType().Subject; e.ParamName.Should().Be("dnsResolver"); @@ -123,29 +92,42 @@ public void constructor_should_throw_when_lookupDomainName_is_null() using var cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = cancellationTokenSource.Token; - var exception = Record.Exception(() => new DnsMonitor(cluster, dnsResolver, "mongodb", null, null, null, cancellationToken)); + var exception = Record.Exception(() => new DnsMonitor(cluster, dnsResolver, "mongodb", null, null, null, null, cancellationToken)); var e = exception.Should().BeOfType().Subject; e.ParamName.Should().Be("lookupDomainName"); } - [Theory] - [InlineData("")] - [InlineData("com")] - [InlineData("a.com")] - public void constructor_should_throw_when_lookupDomainName_is_invalid(string lookupDomainName) + [Fact] + public void constructor_should_throw_when_lookupDomainName_is_empty() { var cluster = Mock.Of(); var dnsResolver = Mock.Of(); using var cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = cancellationTokenSource.Token; - var exception = Record.Exception(() => new DnsMonitor(cluster, dnsResolver, "mongodb", lookupDomainName, null, null, cancellationToken)); + var exception = Record.Exception(() => new DnsMonitor(cluster, dnsResolver, "mongodb", "", null, null, null, cancellationToken)); var e = exception.Should().BeOfType().Subject; e.ParamName.Should().Be("lookupDomainName"); } + [Theory] + [InlineData("localhost", "_mongodb._tcp.localhost")] + [InlineData("mongo.local", "_mongodb._tcp.mongo.local")] + public void constructor_should_accept_a_lookupDomainName_with_fewer_than_three_components(string lookupDomainName, string expectedService) + { + var cluster = Mock.Of(); + var dnsResolver = Mock.Of(); + using var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + var subject = new DnsMonitor(cluster, dnsResolver, "mongodb", lookupDomainName, null, null, null, cancellationToken); + + subject._lookupDomainName().Should().Be(lookupDomainName); + subject._service().Should().Be(expectedService); + } + [Fact] public void Start_should_set_state_to_Running_when_starting() { @@ -283,6 +265,57 @@ public void GetValidEndPoints_should_return_expected_results(string[] srvEndPoin result.Should().Equal(expectedResult); } + [Theory] + [InlineData("x.b.com", "x.b.com")] + [InlineData("X.B.COM", "x.b.com")] + [InlineData("x.b.com.", "x.b.com")] + [InlineData("X.B.COM.", "x.b.com")] + public void GetValidEndPoints_should_normalize_returned_hosts(string srvEndPoint, string expectedHost) + { + var subject = CreateSubject(lookupDomainName: "a.b.com"); + var srvRecords = CreateSrvRecords(new[] { srvEndPoint }); + + var result = subject.GetValidEndPoints(srvRecords); + + result.Select(x => x.Host).Should().Equal(expectedHost); + } + + [Theory] + [InlineData(".b.com", "X.B.COM", "x.b.com")] + [InlineData(".B.COM", "x.b.com", "x.b.com")] + [InlineData("bücher.com", "X.BÜCHER.COM", "x.xn--bcher-kva.com")] + public void GetValidEndPoints_should_normalize_before_matching_srvAllowedHostsSuffix( + string srvAllowedHostsSuffix, + string srvEndPoint, + string expectedHost) + { + var subject = CreateSubject(lookupDomainName: "a.b.com", srvAllowedHostsSuffix: srvAllowedHostsSuffix); + var srvRecords = CreateSrvRecords(new[] { srvEndPoint }); + + var result = subject.GetValidEndPoints(srvRecords); + + result.Select(x => x.Host).Should().Equal(expectedHost); + } + + [Theory] + [InlineData("x..b.com")] + [InlineData("xn--host.b.com")] + public void GetValidEndPoints_should_skip_and_log_a_host_that_cannot_be_normalized(string srvEndPoint) + { + var actualEvents = new List(); + var sdamInformationEventHandler = (Action)(raisedEvent => actualEvents.Add(raisedEvent)); + var mockEventSubscriber = new Mock(); + mockEventSubscriber + .Setup(m => m.TryGetEventHandler(out sdamInformationEventHandler)); + var subject = CreateSubject(lookupDomainName: "a.b.com", eventSubscriber: mockEventSubscriber.Object); + var srvRecords = CreateSrvRecords(new[] { srvEndPoint }); + + var result = subject.GetValidEndPoints(srvRecords); + + result.Should().BeEmpty(); + actualEvents.Should().ContainSingle().Which.Message.Should().Contain(srvEndPoint); + } + [Theory] [InlineData(new string[0], new string[0])] [InlineData(new[] { "x.b.com:27017" }, new string[0])] @@ -313,23 +346,6 @@ public void GetValidEndPoints_should_raise_sdamInformationEvent_for_each_invalid } } - [Theory] - [InlineData("x.B.CoM", "x.b.com")] // parent-domain labels - [InlineData("X1.b.com", "x1.b.com")] // leftmost label only - [InlineData("X1.b.com.", "x1.b.com")] // trailing dot, as the resolver returns it - public void GetValidEndPoints_should_normalize_host_to_lower_case(string srvHost, string expectedHost) - { - var subject = CreateSubject(lookupDomainName: "a.b.com"); - var srvRecords = new List - { - new SrvRecord(new DnsEndPoint(srvHost, 27017), TimeSpan.FromHours(24)) - }; - - var result = subject.GetValidEndPoints(srvRecords); - - result.Single().Host.Should().Be(expectedHost); - } - [Theory] [InlineData("a.b.com", "x.b.com", true)] [InlineData("a.b.com", "x.com", false)] @@ -516,20 +532,19 @@ private DnsMonitor CreateSubject( IDnsResolver dnsResolver = null, string srvServiceName = "mongodb", string lookupDomainName = null, + string srvAllowedHostsSuffix = null, IEventSubscriber eventSubscriber = null, CancellationToken cancellationToken = default) { cluster = cluster ?? Mock.Of(); dnsResolver = dnsResolver ?? Mock.Of(); lookupDomainName = lookupDomainName ?? "a.b.c.com"; - return new DnsMonitor(cluster, dnsResolver, srvServiceName, lookupDomainName, eventSubscriber, null, cancellationToken); + return new DnsMonitor(cluster, dnsResolver, srvServiceName, lookupDomainName, srvAllowedHostsSuffix, eventSubscriber, null, cancellationToken); } } internal static class DnsMonitorReflector { - public static string EnsureLookupDomainNameIsValid(string lookupDomainName) => (string)Reflector.InvokeStatic(typeof(DnsMonitor), nameof(EnsureLookupDomainNameIsValid), lookupDomainName); - public static CancellationToken _cancellationToken(this DnsMonitor obj) => (CancellationToken)Reflector.GetFieldValue(obj, nameof(_cancellationToken)); public static IDnsMonitoringCluster _cluster(this DnsMonitor obj) => (IDnsMonitoringCluster)Reflector.GetFieldValue(obj, nameof(_cluster)); public static IDnsResolver _dnsResolver(this DnsMonitor obj) => (IDnsResolver)Reflector.GetFieldValue(obj, nameof(_dnsResolver)); diff --git a/tests/MongoDB.Driver.Tests/Core/Clusters/LoadBalancedClusterTests.cs b/tests/MongoDB.Driver.Tests/Core/Clusters/LoadBalancedClusterTests.cs index 8a79cdb3e84..ca75be108ba 100644 --- a/tests/MongoDB.Driver.Tests/Core/Clusters/LoadBalancedClusterTests.cs +++ b/tests/MongoDB.Driver.Tests/Core/Clusters/LoadBalancedClusterTests.cs @@ -449,7 +449,7 @@ private Mock CreateMockDnsMonitorFactory() { var mockDnsMonitorFactory = new Mock(); mockDnsMonitorFactory - .Setup(m => m.CreateDnsMonitor(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(m => m.CreateDnsMonitor(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Mock.Of()); return mockDnsMonitorFactory; } diff --git a/tests/MongoDB.Driver.Tests/Core/Clusters/MultiServerClusterTests.cs b/tests/MongoDB.Driver.Tests/Core/Clusters/MultiServerClusterTests.cs index bf08a207179..0d56a1a5bea 100644 --- a/tests/MongoDB.Driver.Tests/Core/Clusters/MultiServerClusterTests.cs +++ b/tests/MongoDB.Driver.Tests/Core/Clusters/MultiServerClusterTests.cs @@ -1178,7 +1178,7 @@ private Mock CreateMockDnsMonitorFactory() { var mockDnsMonitorFactory = new Mock(); mockDnsMonitorFactory - .Setup(m => m.CreateDnsMonitor(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(m => m.CreateDnsMonitor(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Mock.Of()); return mockDnsMonitorFactory; } diff --git a/tests/MongoDB.Driver.Tests/Core/Configuration/ConnectionStringTests.cs b/tests/MongoDB.Driver.Tests/Core/Configuration/ConnectionStringTests.cs index 1725e235332..2c929b0f3c2 100644 --- a/tests/MongoDB.Driver.Tests/Core/Configuration/ConnectionStringTests.cs +++ b/tests/MongoDB.Driver.Tests/Core/Configuration/ConnectionStringTests.cs @@ -1271,6 +1271,79 @@ public void When_srvServiceName_is_specified_without_a_srv_scheme() exception.Message.Should().Contain("srvServiceName"); } + [Theory] + [InlineData("mydomain.net", ".mydomain.net")] + [InlineData(".mydomain.net", ".mydomain.net")] + [InlineData(".mydomain.net.", ".mydomain.net")] + [InlineData(".MyDomain.NET", ".mydomain.net")] + [InlineData("example.公司.cn", ".example.xn--55qx5d.cn")] + [InlineData("пример.рф", ".xn--e1afmkfd.xn--p1ai")] + [InlineData("xn--bcher-kva.com", ".xn--bcher-kva.com")] + [InlineData("internal", ".internal")] + [InlineData("local", ".local")] + [InlineData(".CORP.", ".corp")] + public void NormalizeSrvAllowedHostsSuffix_should_return_expected_result(string suffix, string expectedResult) + { + var result = ConnectionString.NormalizeSrvAllowedHostsSuffix(suffix); + + result.Should().Be(expectedResult); + } + + [Theory] + [InlineData("com")] + [InlineData("mydomain")] + [InlineData("internals")] + public void NormalizeSrvAllowedHostsSuffix_should_throw_when_suffix_is_a_single_label_that_is_not_a_valid_name(string suffix) + { + var exception = Record.Exception(() => ConnectionString.NormalizeSrvAllowedHostsSuffix(suffix)); + + exception.Should().BeOfType(); + exception.Message.Should().Contain("must name at least two domain labels"); + } + + [Theory] + [InlineData("xn--host.mydomain.net")] + [InlineData("XN--HOST.mydomain.net")] + public void NormalizeSrvAllowedHostsSuffix_should_throw_when_suffix_has_no_A_label_form(string suffix) + { + var exception = Record.Exception(() => ConnectionString.NormalizeSrvAllowedHostsSuffix(suffix)); + + exception.Should().BeOfType(); + exception.Message.Should().Contain("is not a valid domain name"); + } + + [Theory] + [InlineData("host.mydomain.net", "host.mydomain.net")] + [InlineData("host.mydomain.net.", "host.mydomain.net")] + [InlineData("HOST.MyDomain.NET", "host.mydomain.net")] + [InlineData("HOST.MyDomain.NET.", "host.mydomain.net")] + [InlineData("host.example.公司.cn", "host.example.xn--55qx5d.cn")] + [InlineData("host.xn--55qx5d.cn", "host.xn--55qx5d.cn")] + [InlineData("xn--bcher-kva.com", "xn--bcher-kva.com")] + [InlineData("XN--BCHER-KVA.com", "xn--bcher-kva.com")] + [InlineData("localhost", "localhost")] + public void TryNormalizeHostName_should_return_expected_result(string host, string expectedResult) + { + var result = ConnectionString.TryNormalizeHostName(host, out var normalizedHost); + + result.Should().BeTrue(); + normalizedHost.Should().Be(expectedResult); + } + + [Theory] + [InlineData("")] + [InlineData(".")] + [InlineData("host..mydomain.net")] + [InlineData("xn--host.mydomain.net")] + [InlineData("XN--HOST.mydomain.net")] + public void TryNormalizeHostName_should_return_false_when_host_has_no_A_label_form(string host) + { + var result = ConnectionString.TryNormalizeHostName(host, out var normalizedHost); + + result.Should().BeFalse(); + normalizedHost.Should().BeNull(); + } + [Theory] [InlineData("mongodb://localhost?proxyHost=222.222.222.12", "222.222.222.12", null, null, null)] [InlineData("mongodb://localhost?proxyHost=222.222.222.12&proxyPort=8080", "222.222.222.12", 8080, null, null)] diff --git a/tests/MongoDB.Driver.Tests/MongoUrlBuilderTests.cs b/tests/MongoDB.Driver.Tests/MongoUrlBuilderTests.cs index ec1aa3dc26e..ae583f8c09f 100644 --- a/tests/MongoDB.Driver.Tests/MongoUrlBuilderTests.cs +++ b/tests/MongoDB.Driver.Tests/MongoUrlBuilderTests.cs @@ -1227,6 +1227,57 @@ public void TestSrvServiceName_WithNonSRVScheme() }); } + [Fact] + public void TestSrvAllowedHostsSuffix() + { + var srvAllowedHostsSuffix = ".mydomain.net"; + var canonicalConnectionString = $"mongodb+srv://localhost/?srvAllowedHostsSuffix={srvAllowedHostsSuffix}"; + + var built = new MongoUrlBuilder { UseTls = true, Scheme = ConnectionStringScheme.MongoDBPlusSrv }; + + built.SrvAllowedHostsSuffix = srvAllowedHostsSuffix; + foreach (var builder in EnumerateBuiltAndParsedBuilders(built, canonicalConnectionString)) + { + Assert.Equal(srvAllowedHostsSuffix, builder.SrvAllowedHostsSuffix); + Assert.Equal(canonicalConnectionString, builder.ToString()); + } + } + + [Fact] + public void TestSrvAllowedHostsSuffix_WithInternationalizedDomain() + { + var srvAllowedHostsSuffix = ".münchen.example.com"; + var canonicalConnectionString = "mongodb+srv://localhost/?srvAllowedHostsSuffix=.m%C3%BCnchen.example.com"; + + var built = new MongoUrlBuilder { UseTls = true, Scheme = ConnectionStringScheme.MongoDBPlusSrv }; + + built.SrvAllowedHostsSuffix = srvAllowedHostsSuffix; + foreach (var builder in EnumerateBuiltAndParsedBuilders(built, canonicalConnectionString)) + { + Assert.Equal(srvAllowedHostsSuffix, builder.SrvAllowedHostsSuffix); + Assert.Equal(canonicalConnectionString, builder.ToString()); + } + } + + [Fact] + public void TestSrvAllowedHostsSuffix_WithSingleLabel() + { + var built = new MongoUrlBuilder { Scheme = ConnectionStringScheme.MongoDBPlusSrv }; + + var exception = Assert.Throws(() => built.SrvAllowedHostsSuffix = ".com"); + + exception.ParamName.Should().Be("SrvAllowedHostsSuffix"); + } + + [Fact] + public void TestSrvAllowedHostsSuffix_WithNonSRVScheme() + { + Assert.Throws(() => + { + _ = new MongoUrlBuilder("mongodb://localhost/?srvAllowedHostsSuffix=.mydomain.net"); + }); + } + [Theory] [InlineData(null, "mongodb://localhost", new[] { "" })] [InlineData(false, "mongodb://localhost/?tls={0}", new[] { "false", "False" })] diff --git a/tests/MongoDB.Driver.Tests/Specifications/initial-dns-seedlist-discovery/prose-tests/SrvPartsTests.cs b/tests/MongoDB.Driver.Tests/Specifications/initial-dns-seedlist-discovery/prose-tests/SrvPartsTests.cs index f93e7095d4f..65b0c8cd6e9 100644 --- a/tests/MongoDB.Driver.Tests/Specifications/initial-dns-seedlist-discovery/prose-tests/SrvPartsTests.cs +++ b/tests/MongoDB.Driver.Tests/Specifications/initial-dns-seedlist-discovery/prose-tests/SrvPartsTests.cs @@ -79,6 +79,34 @@ public void Throw_when_return_address_does_not_contain_dot_separating_shared_par Which.Message.Should().Be("Hosts in the SRV record must have the same parent domain as the seed host."); } + // https://github.com/mongodb/specifications/blob/master/source/initial-dns-seedlist-discovery/tests/README.md#10-accept-a-mixed-case-returned-address-with-srvallowedhostssuffix + [Fact] + public void Accept_mixed_case_return_address_with_srvAllowedHostsSuffix() + { + var uri = "mongodb+srv://blogs.mongodb.com/?srvAllowedHostsSuffix=.mongodb.com"; + var connectionString = new ConnectionString(uri, true, new MockDnsResolver("CLUSTER.MONGODB.COM.")); + + var resolved = connectionString.Resolve(); + + resolved.Hosts.Should().ContainSingle() + .Which.Should().BeOfType() + .Subject.Host.Should().Be("cluster.mongodb.com"); + } + + // https://github.com/mongodb/specifications/blob/master/source/initial-dns-seedlist-discovery/tests/README.md#12-accept-a-reserved-single-label-as-srvallowedhostssuffix + [Fact] + public void Accept_reserved_single_label_srvAllowedHostsSuffix() + { + var uri = "mongodb+srv://cluster.localhost/?srvAllowedHostsSuffix=localhost"; + var connectionString = new ConnectionString(uri, true, new MockDnsResolver("db.cluster.localhost")); + + var resolved = connectionString.Resolve(); + + resolved.Hosts.Should().ContainSingle() + .Which.Should().BeOfType() + .Subject.Host.Should().Be("db.cluster.localhost"); + } + private class MockDnsResolver(string dnsEndPointString) : IDnsResolver { public List ResolveSrvRecords(string service, CancellationToken cancellation) @@ -87,8 +115,9 @@ public List ResolveSrvRecords(string service, CancellationToken cance { new SrvRecord(new DnsEndPoint(dnsEndPointString, 2090), TimeSpan.MaxValue) }; } + // a deployment with no TXT records, which the tests that resolve successfully reach public List ResolveTxtRecords(string domainName, CancellationToken cancellation) - => throw new NotImplementedException(); + => new List(); public Task> ResolveSrvRecordsAsync(string service, CancellationToken cancellation) => throw new NotImplementedException(); From db40400781737501784251b4ecbab887d1071bff Mon Sep 17 00:00:00 2001 From: adelinowona Date: Wed, 16 Sep 2026 20:12:17 -0400 Subject: [PATCH 2/3] CSHARP-6214: set srvAllowedHostsSuffix through a dedicated ClusterSettings method The public ClusterSettings constructor and With overload keep their released signatures; adding an optional parameter to either is a binary break, and an overload carrying the extra parameter makes every call that omits it ambiguous. WithSrvAllowedHostsSuffix sets the value instead, and With carries it across. --- src/MongoDB.Driver/ClusterRegistry.cs | 4 +- .../Core/Configuration/ClusterSettings.cs | 70 +++++++++++++++++-- .../Configuration/ClusterSettingsTests.cs | 23 ++++++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/MongoDB.Driver/ClusterRegistry.cs b/src/MongoDB.Driver/ClusterRegistry.cs index dbdf6789c1f..31b99de513f 100644 --- a/src/MongoDB.Driver/ClusterRegistry.cs +++ b/src/MongoDB.Driver/ClusterRegistry.cs @@ -98,8 +98,8 @@ private ClusterSettings ConfigureCluster(ClusterSettings settings, ClusterKey cl maxServerSelectionWaitQueueSize: clusterKey.WaitQueueSize, serverApi: clusterKey.ServerApi, serverSelectionTimeout: clusterKey.ServerSelectionTimeout, - scheme: clusterKey.Scheme, - srvAllowedHostsSuffix: clusterKey.SrvAllowedHostsSuffix); + scheme: clusterKey.Scheme) + .WithSrvAllowedHostsSuffix(clusterKey.SrvAllowedHostsSuffix); } private ConnectionPoolSettings ConfigureConnectionPool(ConnectionPoolSettings settings, ClusterKey clusterKey) diff --git a/src/MongoDB.Driver/Core/Configuration/ClusterSettings.cs b/src/MongoDB.Driver/Core/Configuration/ClusterSettings.cs index a73b72e4c76..fb2daf3196e 100644 --- a/src/MongoDB.Driver/Core/Configuration/ClusterSettings.cs +++ b/src/MongoDB.Driver/Core/Configuration/ClusterSettings.cs @@ -68,7 +68,6 @@ public class ClusterSettings /// The connection string scheme. /// Limits the number of SRV records used to populate the seedlist during initial discovery, as well as the number of additional hosts that may be added during SRV polling. /// The SRV service name which modifies the srv URI to look like: _{srvServiceName}._tcp.{hostname}.{domainname} Defaults to "mongodb". - /// The hostname suffix that hosts returned by an SRV lookup are validated against, replacing the domain name that would otherwise be inferred from the SRV hostname. WARNING: Modifying the default SRV domain name validation can create vulnerabilities. public ClusterSettings( Optional cryptClientSettings = default, Optional directConnection = default, @@ -83,8 +82,42 @@ public ClusterSettings( Optional postServerSelector = default(Optional), Optional scheme = default(Optional), Optional srvMaxHosts = default, - Optional srvServiceName = default(Optional), - Optional srvAllowedHostsSuffix = default(Optional)) + Optional srvServiceName = default(Optional)) + : this( + cryptClientSettings, + directConnection, + endPoints, + loadBalanced, + localThreshold, + maxServerSelectionWaitQueueSize, + replicaSetName, + serverApi, + serverSelectionTimeout, + preServerSelector, + postServerSelector, + scheme, + srvMaxHosts, + srvServiceName, + srvAllowedHostsSuffix: default) + { + } + + internal ClusterSettings( + Optional cryptClientSettings, + Optional directConnection, + Optional> endPoints, + Optional loadBalanced, + Optional localThreshold, + Optional maxServerSelectionWaitQueueSize, + Optional replicaSetName, + Optional serverApi, + Optional serverSelectionTimeout, + Optional preServerSelector, + Optional postServerSelector, + Optional scheme, + Optional srvMaxHosts, + Optional srvServiceName, + Optional srvAllowedHostsSuffix) { _cryptClientSettings = cryptClientSettings.WithDefault(null); _directConnection = directConnection.WithDefault(false); @@ -274,7 +307,6 @@ public IServerSelector PostServerSelector /// The connection string scheme. /// Limits the number of SRV records used to populate the seedlist during initial discovery, as well as the number of additional hosts that may be added during SRV polling. /// The SRV service name which modifies the srv URI to look like: _{srvServiceName}._tcp.{hostname}.{domainname} Defaults to "mongodb". - /// The hostname suffix that hosts returned by an SRV lookup are validated against, replacing the domain name that would otherwise be inferred from the SRV hostname. WARNING: Modifying the default SRV domain name validation can create vulnerabilities. /// A new ClusterSettings instance. public ClusterSettings With( Optional cryptClientSettings = default, @@ -290,8 +322,7 @@ public ClusterSettings With( Optional postServerSelector = default(Optional), Optional scheme = default(Optional), Optional srvMaxHosts = default, - Optional srvServiceName = default(Optional), - Optional srvAllowedHostsSuffix = default(Optional)) + Optional srvServiceName = default(Optional)) { return new ClusterSettings( cryptClientSettings: cryptClientSettings.WithDefault(_cryptClientSettings), @@ -308,7 +339,32 @@ public ClusterSettings With( scheme: scheme.WithDefault(_scheme), srvMaxHosts: srvMaxHosts.WithDefault(_srvMaxHosts), srvServiceName: srvServiceName.WithDefault(_srvServiceName), - srvAllowedHostsSuffix: srvAllowedHostsSuffix.WithDefault(_srvAllowedHostsSuffix)); + srvAllowedHostsSuffix: _srvAllowedHostsSuffix); + } + + /// + /// Returns a new ClusterSettings instance with the SRV allowed hosts suffix changed. + /// + /// The hostname suffix that hosts returned by an SRV lookup are validated against, replacing the domain name that would otherwise be inferred from the SRV hostname. WARNING: Modifying the default SRV domain name validation can create vulnerabilities. + /// A new ClusterSettings instance. + public ClusterSettings WithSrvAllowedHostsSuffix(string srvAllowedHostsSuffix) + { + return new ClusterSettings( + cryptClientSettings: _cryptClientSettings, + directConnection: _directConnection, + endPoints: Optional.Enumerable(_endPoints), + loadBalanced: Optional.Create(_loadBalanced), + localThreshold: _localThreshold, + maxServerSelectionWaitQueueSize: _maxServerSelectionWaitQueueSize, + replicaSetName: _replicaSetName, + serverApi: _serverApi, + serverSelectionTimeout: _serverSelectionTimeout, + preServerSelector: Optional.Create(_preServerSelector), + postServerSelector: Optional.Create(_postServerSelector), + scheme: _scheme, + srvMaxHosts: _srvMaxHosts, + srvServiceName: _srvServiceName, + srvAllowedHostsSuffix: srvAllowedHostsSuffix); } // internal methods diff --git a/tests/MongoDB.Driver.Tests/Core/Configuration/ClusterSettingsTests.cs b/tests/MongoDB.Driver.Tests/Core/Configuration/ClusterSettingsTests.cs index 8d735a72103..63f54071d89 100644 --- a/tests/MongoDB.Driver.Tests/Core/Configuration/ClusterSettingsTests.cs +++ b/tests/MongoDB.Driver.Tests/Core/Configuration/ClusterSettingsTests.cs @@ -43,6 +43,7 @@ public void constructor_should_initialize_instance() subject.Scheme.Should().Be(ConnectionStringScheme.MongoDB); subject.ServerApi.Should().BeNull(); subject.ServerSelectionTimeout.Should().Be(TimeSpan.FromSeconds(30)); + subject.SrvAllowedHostsSuffix.Should().BeNull(); subject.SrvMaxHosts.Should().Be(0); subject.SrvServiceName.Should().Be("mongodb"); } @@ -492,6 +493,28 @@ public void With_srvServiceName_should_return_expected_result() result.SrvServiceName.Should().Be(srvServiceName); } + [Fact] + public void WithSrvAllowedHostsSuffix_should_return_expected_result() + { + var srvAllowedHostsSuffix = ".mydomain.net"; + var subject = new ClusterSettings(); + + var result = subject.WithSrvAllowedHostsSuffix(srvAllowedHostsSuffix); + + result.SrvAllowedHostsSuffix.Should().Be(srvAllowedHostsSuffix); + } + + [Fact] + public void With_should_preserve_srvAllowedHostsSuffix() + { + var srvAllowedHostsSuffix = ".mydomain.net"; + var subject = new ClusterSettings().WithSrvAllowedHostsSuffix(srvAllowedHostsSuffix); + + var result = subject.With(srvServiceName: "customname"); + + result.SrvAllowedHostsSuffix.Should().Be(srvAllowedHostsSuffix); + } + [Fact] public void With_negative_srvMaxHosts_should_throw() { From 2d2941deaa3e3051cbff11b1b909686f78141d28 Mon Sep 17 00:00:00 2001 From: adelinowona Date: Wed, 16 Sep 2026 20:45:59 -0400 Subject: [PATCH 3/3] CSHARP-6214: apply the A-label decode check to the netstandard2.1 build The check compensates for the NLS globalization backend, which returns all-ASCII input from GetAscii without decoding it. .NET Core 3.1 on Windows runs the netstandard2.1 build on NLS, so that target needs the check as well. --- .../Core/Configuration/ConnectionString.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs b/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs index 141547aaf2b..756f77398b6 100644 --- a/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs +++ b/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs @@ -1407,10 +1407,11 @@ private static string TryGetAsciiForm(string value, out string errorDetail) return null; } -#if NETFRAMEWORK - // .NET Core rejects a label that starts with "xn--" and does not decode as Punycode, - // but .NET Framework returns all-ASCII input unchanged without decoding it. Decoding - // the A-labels here keeps the validation identical on every target framework. +#if NETFRAMEWORK || NETSTANDARD2_1 + // ICU rejects a label that starts with "xn--" and does not decode as Punycode, but the + // NLS backend used by .NET Framework and by .NET Core 3.1 on Windows returns all-ASCII + // input unchanged without decoding it. Decoding the A-labels here keeps the validation + // identical on every target framework. foreach (var label in ascii.Split('.')) { if (label.StartsWith("xn--", StringComparison.Ordinal))