Skip to content

Commit 1c813dc

Browse files
authored
PYTHON-4575 Allow valid SRV hostnames with less than 3 parts (#2234)
1 parent e7c0814 commit 1c813dc

File tree

7 files changed

+193
-20
lines changed

7 files changed

+193
-20
lines changed

doc/changelog.rst

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ PyMongo 4.12 brings a number of changes including:
2424
:class:`~pymongo.read_preferences.SecondaryPreferred`,
2525
:class:`~pymongo.read_preferences.Nearest`. Support for ``hedge`` will be removed in PyMongo 5.0.
2626
- Removed PyOpenSSL support from the asynchronous API due to limitations of the CPython asyncio.Protocol SSL implementation.
27+
- Allow valid SRV hostnames with less than 3 parts.
2728

2829
Issues Resolved
2930
...............

pymongo/asynchronous/srv_resolver.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,12 @@ def __init__(
9090
raise ConfigurationError(_INVALID_HOST_MSG % ("an IP address",))
9191
except ValueError:
9292
pass
93-
9493
try:
95-
self.__plist = self.__fqdn.split(".")[1:]
94+
split_fqdn = self.__fqdn.split(".")
95+
self.__plist = split_fqdn[1:] if len(split_fqdn) > 2 else split_fqdn
9696
except Exception:
9797
raise ConfigurationError(_INVALID_HOST_MSG % (fqdn,)) from None
9898
self.__slen = len(self.__plist)
99-
if self.__slen < 2:
100-
raise ConfigurationError(_INVALID_HOST_MSG % (fqdn,))
10199

102100
async def get_options(self) -> Optional[str]:
103101
from dns import resolver
@@ -139,6 +137,10 @@ async def _get_srv_response_and_hosts(
139137

140138
# Validate hosts
141139
for node in nodes:
140+
if self.__fqdn == node[0].lower():
141+
raise ConfigurationError(
142+
"Invalid SRV host: return address is identical to SRV hostname"
143+
)
142144
try:
143145
nlist = node[0].lower().split(".")[1:][-self.__slen :]
144146
except Exception:

pymongo/synchronous/srv_resolver.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,12 @@ def __init__(
9090
raise ConfigurationError(_INVALID_HOST_MSG % ("an IP address",))
9191
except ValueError:
9292
pass
93-
9493
try:
95-
self.__plist = self.__fqdn.split(".")[1:]
94+
split_fqdn = self.__fqdn.split(".")
95+
self.__plist = split_fqdn[1:] if len(split_fqdn) > 2 else split_fqdn
9696
except Exception:
9797
raise ConfigurationError(_INVALID_HOST_MSG % (fqdn,)) from None
9898
self.__slen = len(self.__plist)
99-
if self.__slen < 2:
100-
raise ConfigurationError(_INVALID_HOST_MSG % (fqdn,))
10199

102100
def get_options(self) -> Optional[str]:
103101
from dns import resolver
@@ -139,6 +137,10 @@ def _get_srv_response_and_hosts(
139137

140138
# Validate hosts
141139
for node in nodes:
140+
if self.__fqdn == node[0].lower():
141+
raise ConfigurationError(
142+
"Invalid SRV host: return address is identical to SRV hostname"
143+
)
142144
try:
143145
nlist = node[0].lower().split(".")[1:][-self.__slen :]
144146
except Exception:

test/asynchronous/test_dns.py

+89-6
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
unittest,
3131
)
3232
from test.utils_shared import async_wait_until
33+
from unittest.mock import MagicMock, patch
3334

3435
from pymongo.asynchronous.uri_parser import parse_uri
3536
from pymongo.common import validate_read_preference_tags
@@ -186,12 +187,6 @@ def create_tests(cls):
186187

187188
class TestParsingErrors(AsyncPyMongoTestCase):
188189
async def test_invalid_host(self):
189-
with self.assertRaisesRegex(ConfigurationError, "Invalid URI host: mongodb is not"):
190-
client = self.simple_client("mongodb+srv://mongodb")
191-
await client.aconnect()
192-
with self.assertRaisesRegex(ConfigurationError, "Invalid URI host: mongodb.com is not"):
193-
client = self.simple_client("mongodb+srv://mongodb.com")
194-
await client.aconnect()
195190
with self.assertRaisesRegex(ConfigurationError, "Invalid URI host: an IP address is not"):
196191
client = self.simple_client("mongodb+srv://127.0.0.1")
197192
await client.aconnect()
@@ -207,5 +202,93 @@ async def test_connect_case_insensitive(self):
207202
self.assertGreater(len(client.topology_description.server_descriptions()), 1)
208203

209204

205+
class TestInitialDnsSeedlistDiscovery(AsyncPyMongoTestCase):
206+
"""
207+
Initial DNS Seedlist Discovery prose tests
208+
https://github.com/mongodb/specifications/blob/0a7a8b5/source/initial-dns-seedlist-discovery/tests/README.md#prose-tests
209+
"""
210+
211+
async def run_initial_dns_seedlist_discovery_prose_tests(self, test_cases):
212+
for case in test_cases:
213+
with patch("dns.asyncresolver.resolve") as mock_resolver:
214+
215+
async def mock_resolve(query, record_type, *args, **kwargs):
216+
mock_srv = MagicMock()
217+
mock_srv.target.to_text.return_value = case["mock_target"]
218+
return [mock_srv]
219+
220+
mock_resolver.side_effect = mock_resolve
221+
domain = case["query"].split("._tcp.")[1]
222+
connection_string = f"mongodb+srv://{domain}"
223+
try:
224+
await parse_uri(connection_string)
225+
except ConfigurationError as e:
226+
self.assertIn(case["expected_error"], str(e))
227+
else:
228+
self.fail(f"ConfigurationError was not raised for query: {case['query']}")
229+
230+
async def test_1_allow_srv_hosts_with_fewer_than_three_dot_separated_parts(self):
231+
with patch("dns.asyncresolver.resolve"):
232+
await parse_uri("mongodb+srv://localhost/")
233+
await parse_uri("mongodb+srv://mongo.local/")
234+
235+
async def test_2_throw_when_return_address_does_not_end_with_srv_domain(self):
236+
test_cases = [
237+
{
238+
"query": "_mongodb._tcp.localhost",
239+
"mock_target": "localhost.mongodb",
240+
"expected_error": "Invalid SRV host",
241+
},
242+
{
243+
"query": "_mongodb._tcp.blogs.mongodb.com",
244+
"mock_target": "blogs.evil.com",
245+
"expected_error": "Invalid SRV host",
246+
},
247+
{
248+
"query": "_mongodb._tcp.blogs.mongo.local",
249+
"mock_target": "test_1.evil.com",
250+
"expected_error": "Invalid SRV host",
251+
},
252+
]
253+
await self.run_initial_dns_seedlist_discovery_prose_tests(test_cases)
254+
255+
async def test_3_throw_when_return_address_is_identical_to_srv_hostname(self):
256+
test_cases = [
257+
{
258+
"query": "_mongodb._tcp.localhost",
259+
"mock_target": "localhost",
260+
"expected_error": "Invalid SRV host",
261+
},
262+
{
263+
"query": "_mongodb._tcp.mongo.local",
264+
"mock_target": "mongo.local",
265+
"expected_error": "Invalid SRV host",
266+
},
267+
]
268+
await self.run_initial_dns_seedlist_discovery_prose_tests(test_cases)
269+
270+
async def test_4_throw_when_return_address_does_not_contain_dot_separating_shared_part_of_domain(
271+
self
272+
):
273+
test_cases = [
274+
{
275+
"query": "_mongodb._tcp.localhost",
276+
"mock_target": "test_1.cluster_1localhost",
277+
"expected_error": "Invalid SRV host",
278+
},
279+
{
280+
"query": "_mongodb._tcp.mongo.local",
281+
"mock_target": "test_1.my_hostmongo.local",
282+
"expected_error": "Invalid SRV host",
283+
},
284+
{
285+
"query": "_mongodb._tcp.blogs.mongodb.com",
286+
"mock_target": "cluster.testmongodb.com",
287+
"expected_error": "Invalid SRV host",
288+
},
289+
]
290+
await self.run_initial_dns_seedlist_discovery_prose_tests(test_cases)
291+
292+
210293
if __name__ == "__main__":
211294
unittest.main()

test/test_dns.py

+89-6
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
unittest,
3131
)
3232
from test.utils_shared import wait_until
33+
from unittest.mock import MagicMock, patch
3334

3435
from pymongo.common import validate_read_preference_tags
3536
from pymongo.errors import ConfigurationError
@@ -184,12 +185,6 @@ def create_tests(cls):
184185

185186
class TestParsingErrors(PyMongoTestCase):
186187
def test_invalid_host(self):
187-
with self.assertRaisesRegex(ConfigurationError, "Invalid URI host: mongodb is not"):
188-
client = self.simple_client("mongodb+srv://mongodb")
189-
client._connect()
190-
with self.assertRaisesRegex(ConfigurationError, "Invalid URI host: mongodb.com is not"):
191-
client = self.simple_client("mongodb+srv://mongodb.com")
192-
client._connect()
193188
with self.assertRaisesRegex(ConfigurationError, "Invalid URI host: an IP address is not"):
194189
client = self.simple_client("mongodb+srv://127.0.0.1")
195190
client._connect()
@@ -205,5 +200,93 @@ def test_connect_case_insensitive(self):
205200
self.assertGreater(len(client.topology_description.server_descriptions()), 1)
206201

207202

203+
class TestInitialDnsSeedlistDiscovery(PyMongoTestCase):
204+
"""
205+
Initial DNS Seedlist Discovery prose tests
206+
https://github.com/mongodb/specifications/blob/0a7a8b5/source/initial-dns-seedlist-discovery/tests/README.md#prose-tests
207+
"""
208+
209+
def run_initial_dns_seedlist_discovery_prose_tests(self, test_cases):
210+
for case in test_cases:
211+
with patch("dns.resolver.resolve") as mock_resolver:
212+
213+
def mock_resolve(query, record_type, *args, **kwargs):
214+
mock_srv = MagicMock()
215+
mock_srv.target.to_text.return_value = case["mock_target"]
216+
return [mock_srv]
217+
218+
mock_resolver.side_effect = mock_resolve
219+
domain = case["query"].split("._tcp.")[1]
220+
connection_string = f"mongodb+srv://{domain}"
221+
try:
222+
parse_uri(connection_string)
223+
except ConfigurationError as e:
224+
self.assertIn(case["expected_error"], str(e))
225+
else:
226+
self.fail(f"ConfigurationError was not raised for query: {case['query']}")
227+
228+
def test_1_allow_srv_hosts_with_fewer_than_three_dot_separated_parts(self):
229+
with patch("dns.resolver.resolve"):
230+
parse_uri("mongodb+srv://localhost/")
231+
parse_uri("mongodb+srv://mongo.local/")
232+
233+
def test_2_throw_when_return_address_does_not_end_with_srv_domain(self):
234+
test_cases = [
235+
{
236+
"query": "_mongodb._tcp.localhost",
237+
"mock_target": "localhost.mongodb",
238+
"expected_error": "Invalid SRV host",
239+
},
240+
{
241+
"query": "_mongodb._tcp.blogs.mongodb.com",
242+
"mock_target": "blogs.evil.com",
243+
"expected_error": "Invalid SRV host",
244+
},
245+
{
246+
"query": "_mongodb._tcp.blogs.mongo.local",
247+
"mock_target": "test_1.evil.com",
248+
"expected_error": "Invalid SRV host",
249+
},
250+
]
251+
self.run_initial_dns_seedlist_discovery_prose_tests(test_cases)
252+
253+
def test_3_throw_when_return_address_is_identical_to_srv_hostname(self):
254+
test_cases = [
255+
{
256+
"query": "_mongodb._tcp.localhost",
257+
"mock_target": "localhost",
258+
"expected_error": "Invalid SRV host",
259+
},
260+
{
261+
"query": "_mongodb._tcp.mongo.local",
262+
"mock_target": "mongo.local",
263+
"expected_error": "Invalid SRV host",
264+
},
265+
]
266+
self.run_initial_dns_seedlist_discovery_prose_tests(test_cases)
267+
268+
def test_4_throw_when_return_address_does_not_contain_dot_separating_shared_part_of_domain(
269+
self
270+
):
271+
test_cases = [
272+
{
273+
"query": "_mongodb._tcp.localhost",
274+
"mock_target": "test_1.cluster_1localhost",
275+
"expected_error": "Invalid SRV host",
276+
},
277+
{
278+
"query": "_mongodb._tcp.mongo.local",
279+
"mock_target": "test_1.my_hostmongo.local",
280+
"expected_error": "Invalid SRV host",
281+
},
282+
{
283+
"query": "_mongodb._tcp.blogs.mongodb.com",
284+
"mock_target": "cluster.testmongodb.com",
285+
"expected_error": "Invalid SRV host",
286+
},
287+
]
288+
self.run_initial_dns_seedlist_discovery_prose_tests(test_cases)
289+
290+
208291
if __name__ == "__main__":
209292
unittest.main()

test/test_uri_parser.py

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
sys.path[0:0] = [""]
2525

2626
from test import unittest
27+
from unittest.mock import patch
2728

2829
from bson.binary import JAVA_LEGACY
2930
from pymongo import ReadPreference

tools/synchro.py

+1
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
"async_joinall": "joinall",
134134
"_async_create_connection": "_create_connection",
135135
"pymongo.asynchronous.srv_resolver._SrvResolver.get_hosts": "pymongo.synchronous.srv_resolver._SrvResolver.get_hosts",
136+
"dns.asyncresolver.resolve": "dns.resolver.resolve",
136137
}
137138

138139
docstring_replacements: dict[tuple[str, str], str] = {

0 commit comments

Comments
 (0)