diff --git a/doc/changelog.rst b/doc/changelog.rst index 41e6dee233..40fb365e64 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -7,6 +7,17 @@ Changes in Version 4.19.0 (2026/XX/XX) Bug fixes ......... +- Added the ``srv_host_validator`` keyword argument to + :class:`~pymongo.synchronous.mongo_client.MongoClient` and + :class:`~pymongo.asynchronous.mongo_client.AsyncMongoClient`, an alternative to + ``srvAllowedHostsSuffix`` for deployments whose acceptable SRV hosts cannot be + expressed as a single suffix. The callback is invoked once per SRV-returned + host and returns ``True`` to accept it. It is mutually exclusive with + ``srvAllowedHostsSuffix`` and, because it takes a callable, cannot be set in a + connection string. See the + :class:`~pymongo.synchronous.mongo_client.MongoClient` and + :class:`~pymongo.asynchronous.mongo_client.AsyncMongoClient` documentation for + security considerations. - Fixed a bug where the synchronous client could permanently deadlock under gevent when a greenlet was killed while checking a connection back into the pool (`PYTHON-6074`_). diff --git a/pymongo/_psl.py b/pymongo/_psl.py index b8f11f5bd7..a2f8be1bdc 100644 --- a/pymongo/_psl.py +++ b/pymongo/_psl.py @@ -21,6 +21,26 @@ _PUBLIC_SUFFIXES: Optional[tuple[set[str], set[str], set[str]]] = None +# Single labels that srvAllowedHostsSuffix may be set to. See the +# srvAllowedHostsSuffix section of the Initial DNS Seedlist Discovery spec. +SPECIAL_USE_LABELS = frozenset( + [ + # RFC 6761 special use names. + "test", + "localhost", + "invalid", + "example", + # RFC 6762 multicast DNS. + "local", + # Reserved by ICANN for private use. + "internal", + # Not officially reserved by ICANN but commonly used privately. + "corp", + "home", + "mail", + ] +) + def _to_punycode(string: str) -> str: """Convert a string to Punycode.""" diff --git a/pymongo/asynchronous/mongo_client.py b/pymongo/asynchronous/mongo_client.py index e347e1de56..ff7fa2c1ab 100644 --- a/pymongo/asynchronous/mongo_client.py +++ b/pymongo/asynchronous/mongo_client.py @@ -470,6 +470,31 @@ def __init__( srvAllowedHostsSuffix=".internal.example.com", ) + - `srv_host_validator`: (callable or None) A callback used in place of the + default parent-domain check for hosts returned by SRV DNS records. It is + called once per returned host with the lowercased hostname as its only + argument, and must return ``True`` to accept the host or ``False`` to + reject it. Rejecting a host raises + :exc:`~pymongo.errors.ConfigurationError`, as does an exception raised by + the callback itself. Use this when the set of acceptable hosts cannot be + expressed as a single suffix:: + + def validator(host: str) -> bool: + return host.endswith((".a.example.com", ".b.example.com")) + + AsyncMongoClient( + "mongodb+srv://cluster.example.com/", + srv_host_validator=validator, + ) + + The callback must not block. It is mutually exclusive with + ``srvAllowedHostsSuffix``. Because this option is a callable, it can + only be passed in as a keyword argument, not through the connection string. + + .. warning:: + + This option replaces the built-in DNS spoofing safeguards. + Please use with caution. | **Write Concern options:** | (Only set if passed. No default values.) @@ -823,6 +848,7 @@ def __init__( srv_service_name = keyword_opts.get("srvservicename") srv_max_hosts = keyword_opts.get("srvmaxhosts") srv_allowed_hosts_suffix = keyword_opts.get("srvallowedhostssuffix") + srv_host_validator = keyword_opts.get("srv_host_validator") if len([h for h in self._host if "/" in h]) > 1: raise ConfigurationError("host must not contain multiple MongoDB URIs") for entity in self._host: @@ -915,7 +941,11 @@ def __init__( ) self._init_based_on_options( - self._seeds, srv_max_hosts, srv_service_name, srv_allowed_hosts_suffix + self._seeds, + srv_max_hosts, + srv_service_name, + srv_allowed_hosts_suffix, + srv_host_validator, ) self._opened = False @@ -935,6 +965,7 @@ async def _resolve_srv(self) -> None: srv_service_name = keyword_opts.get("srvservicename") srv_max_hosts = keyword_opts.get("srvmaxhosts") srv_allowed_hosts_suffix = keyword_opts.get("srvallowedhostssuffix") + srv_host_validator = keyword_opts.get("srv_host_validator") for entity in self._host: # A hostname can only include a-z, 0-9, '-' and '.'. If we find a '/' # it must be a URI, @@ -956,6 +987,7 @@ async def _resolve_srv(self) -> None: srv_service_name=srv_service_name, srv_max_hosts=srv_max_hosts, srv_allowed_hosts_suffix=srv_allowed_hosts_suffix, + srv_host_validator=srv_host_validator, ) seeds.update(res["nodelist"]) opts = res["options"] @@ -1000,7 +1032,11 @@ async def _resolve_srv(self) -> None: ) self._init_based_on_options( - seeds, srv_max_hosts, srv_service_name, srv_allowed_hosts_suffix + seeds, + srv_max_hosts, + srv_service_name, + srv_allowed_hosts_suffix, + srv_host_validator, ) def _init_based_on_options( @@ -1009,7 +1045,17 @@ def _init_based_on_options( srv_max_hosts: Any, srv_service_name: Any, srv_allowed_hosts_suffix: Any, + srv_host_validator: Any = None, ) -> None: + if srv_host_validator is not None: + if srv_allowed_hosts_suffix is not None: + raise ConfigurationError( + "Cannot specify both srv_host_validator and srvAllowedHostsSuffix" + ) + if not self._resolve_srv_info["is_srv"]: + raise ConfigurationError( + "The srv_host_validator option is only allowed with 'mongodb+srv://' URIs" + ) self._event_listeners = self._options.pool_options._event_listeners self._topology_settings = TopologySettings( seeds=seeds, @@ -1028,6 +1074,7 @@ def _init_based_on_options( srv_service_name=srv_service_name, srv_max_hosts=srv_max_hosts, srv_allowed_hosts_suffix=srv_allowed_hosts_suffix, + srv_host_validator=srv_host_validator, server_monitoring_mode=self._options.server_monitoring_mode, topology_id=self._topology_settings._topology_id if self._topology_settings else None, ) diff --git a/pymongo/asynchronous/monitor.py b/pymongo/asynchronous/monitor.py index ba0e3804a6..7700dfd2e0 100644 --- a/pymongo/asynchronous/monitor.py +++ b/pymongo/asynchronous/monitor.py @@ -367,6 +367,7 @@ async def _get_seedlist(self) -> Optional[list[tuple[str, Any]]]: self._settings.pool_options.connect_timeout, self._settings.srv_service_name, srv_allowed_hosts_suffix=self._settings.srv_allowed_hosts_suffix, + srv_host_validator=self._settings.srv_host_validator, ) seedlist, ttl = await resolver.get_hosts_and_min_ttl() if len(seedlist) == 0: diff --git a/pymongo/asynchronous/settings.py b/pymongo/asynchronous/settings.py index bdfb334ab7..4bc5f05f28 100644 --- a/pymongo/asynchronous/settings.py +++ b/pymongo/asynchronous/settings.py @@ -18,7 +18,7 @@ import threading from collections.abc import Collection -from typing import Optional +from typing import Callable, Optional from bson.objectid import ObjectId from pymongo import common @@ -51,6 +51,7 @@ def __init__( srv_service_name: str = common.SRV_SERVICE_NAME, srv_max_hosts: int = 0, srv_allowed_hosts_suffix: Optional[str] = None, + srv_host_validator: Optional[Callable[[str], bool]] = None, server_monitoring_mode: str = common.SERVER_MONITORING_MODE, topology_id: Optional[ObjectId] = None, ): @@ -77,6 +78,7 @@ def __init__( srv_service_name, srv_max_hosts, srv_allowed_hosts_suffix, + srv_host_validator, server_monitoring_mode, topology_id, ) diff --git a/pymongo/asynchronous/srv_resolver.py b/pymongo/asynchronous/srv_resolver.py index 5a068dbe72..bb0357951c 100644 --- a/pymongo/asynchronous/srv_resolver.py +++ b/pymongo/asynchronous/srv_resolver.py @@ -18,9 +18,9 @@ import ipaddress import random -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, Callable, Optional, Union -from pymongo._psl import is_public_suffix +from pymongo._psl import SPECIAL_USE_LABELS, _to_punycode, is_public_suffix from pymongo.common import CONNECT_TIMEOUT from pymongo.errors import ConfigurationError @@ -64,21 +64,42 @@ def __init__( srv_service_name: str, srv_max_hosts: int = 0, srv_allowed_hosts_suffix: Optional[str] = None, + srv_host_validator: Optional[Callable[[str], bool]] = None, ): self.__fqdn = fqdn.lower() self.__srv = srv_service_name self.__connect_timeout = connect_timeout or CONNECT_TIMEOUT self.__srv_max_hosts = srv_max_hosts or 0 - self.__srv_allowed_hosts_suffix = ( - "." + srv_allowed_hosts_suffix.lower().strip(".") if srv_allowed_hosts_suffix else None - ) # ensure there's a . at the beginning of the domain - if self.__srv_allowed_hosts_suffix is not None and is_public_suffix( - self.__srv_allowed_hosts_suffix - ): + self.__srv_host_validator = srv_host_validator + # AsyncMongoClient rejects this combination earlier and with a better + # error, but parse_uri() reaches this constructor directly. Checking + # here too ensures srvAllowedHostsSuffix is never silently discarded. + if srv_host_validator is not None and srv_allowed_hosts_suffix is not None: raise ConfigurationError( - f"srvAllowedHostsSuffix must not be a public suffix, got: {srv_allowed_hosts_suffix}" + "Cannot specify both srv_host_validator and srvAllowedHostsSuffix" ) - # Validate the fully qualified domain name. + self.__srv_allowed_hosts_suffix = None + if srv_allowed_hosts_suffix is not None: + suffix = srv_allowed_hosts_suffix.strip(".") + if not suffix: + raise ConfigurationError( + f"srvAllowedHostsSuffix must not be empty, got: {srv_allowed_hosts_suffix!r}" + ) + suffix = _to_punycode(suffix).lower() + + is_special_use = suffix in SPECIAL_USE_LABELS + if len(suffix.split(".")) < 2 and not is_special_use: + raise ConfigurationError( + "srvAllowedHostsSuffix must contain at least two '.' separated labels, " + f"got: {srv_allowed_hosts_suffix}" + ) + + if not is_special_use and is_public_suffix(suffix): + raise ConfigurationError( + f"srvAllowedHostsSuffix must not be a public suffix, got: {srv_allowed_hosts_suffix}" + ) + self.__srv_allowed_hosts_suffix = "." + suffix + try: ipaddress.ip_address(fqdn) raise ConfigurationError(_INVALID_HOST_MSG % ("an IP address",)) @@ -133,14 +154,25 @@ async def _get_srv_response_and_hosts( # Validate hosts for node in nodes: srv_host = node[0].lower() - if self.__fqdn == srv_host and self.nparts < 3: - raise ConfigurationError( - "Invalid SRV host: return address is identical to SRV hostname" - ) - if self.__srv_allowed_hosts_suffix is not None: + if self.__srv_host_validator is not None: + try: + allowed = self.__srv_host_validator(srv_host) + except Exception as exc: + raise ConfigurationError( + f"srv_host_validator raised an exception for SRV host {node[0]}: {exc}" + ) from exc + if not allowed: + raise ConfigurationError( + f"Invalid SRV host: {node[0]} was rejected by srv_host_validator" + ) + elif self.__srv_allowed_hosts_suffix is not None: if not srv_host.endswith(self.__srv_allowed_hosts_suffix): raise ConfigurationError(f"Invalid SRV host: {node[0]}") else: + if self.__fqdn == srv_host and self.nparts < 3: + raise ConfigurationError( + "Invalid SRV host: return address is identical to SRV hostname" + ) try: nlist = srv_host.split(".")[1:][-self.__slen :] except Exception as exc: diff --git a/pymongo/asynchronous/uri_parser.py b/pymongo/asynchronous/uri_parser.py index 235e7e1dd3..eaf3f6fbd9 100644 --- a/pymongo/asynchronous/uri_parser.py +++ b/pymongo/asynchronous/uri_parser.py @@ -17,7 +17,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any, Callable, Optional from urllib.parse import unquote_plus from pymongo.asynchronous.srv_resolver import _SrvResolver @@ -49,6 +49,7 @@ async def parse_uri( srv_service_name: Optional[str] = None, srv_max_hosts: Optional[int] = None, srv_allowed_hosts_suffix: Optional[str] = None, + srv_host_validator: Optional[Callable[[str], bool]] = None, ) -> dict[str, Any]: """Parse and validate a MongoDB URI. @@ -118,6 +119,7 @@ async def parse_uri( srv_service_name, srv_max_hosts, srv_allowed_hosts_suffix, + srv_host_validator, ) ) result["options"] = _make_options_case_sensitive(result["options"]) @@ -134,6 +136,7 @@ async def _parse_srv( srv_service_name: Optional[str] = None, srv_max_hosts: Optional[int] = None, srv_allowed_hosts_suffix: Optional[str] = None, + srv_host_validator: Optional[Callable[[str], bool]] = None, ) -> dict[str, Any]: if uri.startswith(SCHEME): is_srv = False @@ -170,7 +173,12 @@ async def _parse_srv( # argument overrides the same option passed in the connection string. connect_timeout = connect_timeout or options.get("connectTimeoutMS") dns_resolver = _SrvResolver( - fqdn, connect_timeout, srv_service_name, srv_max_hosts, srv_allowed_hosts_suffix + fqdn, + connect_timeout, + srv_service_name, + srv_max_hosts, + srv_allowed_hosts_suffix, + srv_host_validator, ) nodes = await dns_resolver.get_hosts() dns_options = await dns_resolver.get_options() diff --git a/pymongo/common.py b/pymongo/common.py index dc18b937e5..9fa3bb15db 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -784,6 +784,7 @@ def validate_server_monitoring_mode(option: str, value: str) -> str: "username": validate_string_or_none, "password": validate_string_or_none, "server_selector": validate_is_callable_or_none, + "srv_host_validator": validate_is_callable_or_none, "auto_encryption_opts": validate_auto_encryption_opts_or_none, "authoidcallowedhosts": validate_list, "max_adaptive_retries": validate_non_negative_integer, diff --git a/pymongo/public_suffix_list.dat b/pymongo/public_suffix_list.dat index 78da70c4d4..0f09a04291 100644 --- a/pymongo/public_suffix_list.dat +++ b/pymongo/public_suffix_list.dat @@ -6962,7 +6962,6 @@ ltd.ua a2hosted.com cpserver.com activetrail.biz -adaptable.app myaddr.dev myaddr.io dyn.addr.tools @@ -6982,7 +6981,7 @@ africa.com *.auiusercontent.com beep.pl aiven.app -aivencloud.com +*.aivencloud.com akadns.net akamai.net akamai-staging.net @@ -7857,6 +7856,9 @@ eero-stage.online opentunnel.xyz antagonist.cloud claude.app +claudeusercontent.com +frame.claudeusercontent.com +*.cursorusercontent.com apigee.io panel.dev siiites.com @@ -8069,6 +8071,8 @@ sch.ac dev.cv store.cv codeberg.page +codepen.app +codepen.dev csb.app preview.csb.app co.nl @@ -8948,6 +8952,7 @@ hepforge.org onhercules.app hercules-app.com hercules-dev.com +here.now herokuapp.com heyflow.page heyflow.site @@ -9043,6 +9048,7 @@ botdash.gg botdash.net botda.sh botdash.xyz +online-server.cloud apps-1and1.com live-website.com webspace-host.com @@ -9882,6 +9888,9 @@ scbl.pl-waw.scw.cloud scalebook.scw.cloud smartlabeling.scw.cloud dedibox.fr +scw.site +ams.scw.site +waw.scw.site schokokeks.net gov.scot service.gov.scot @@ -10175,9 +10184,9 @@ toolforge.org wmcloud.org beta.wmcloud.org wmflabs.org -vps.hrsn.au hrsn.dev is-a.dev +vps.hrsn.net localcert.net windsurf.app windsurf.build diff --git a/pymongo/settings_shared.py b/pymongo/settings_shared.py index fbe87697c1..bf45c40334 100644 --- a/pymongo/settings_shared.py +++ b/pymongo/settings_shared.py @@ -19,7 +19,7 @@ import threading import traceback from collections.abc import Collection -from typing import Any, Generic, Optional, TypeVar, Union +from typing import Any, Callable, Generic, Optional, TypeVar, Union from bson.objectid import ObjectId from pymongo import common @@ -52,6 +52,7 @@ def __init__( srv_service_name: str = common.SRV_SERVICE_NAME, srv_max_hosts: int = 0, srv_allowed_hosts_suffix: Optional[str] = None, + srv_host_validator: Optional[Callable[[str], bool]] = None, server_monitoring_mode: str = common.SERVER_MONITORING_MODE, topology_id: Optional[ObjectId] = None, ): @@ -79,6 +80,7 @@ def __init__( self._srv_service_name = srv_service_name self._srv_max_hosts = srv_max_hosts or 0 self._srv_allowed_hosts_suffix = srv_allowed_hosts_suffix + self._srv_host_validator = srv_host_validator self._server_monitoring_mode = server_monitoring_mode if topology_id is not None: self._topology_id = topology_id @@ -161,6 +163,11 @@ def srv_allowed_hosts_suffix(self) -> Optional[str]: """The srvAllowedHostsSuffix.""" return self._srv_allowed_hosts_suffix + @property + def srv_host_validator(self) -> Optional[Callable[[str], bool]]: + """The srv_host_validator callback.""" + return self._srv_host_validator + @property def server_monitoring_mode(self) -> str: """The serverMonitoringMode.""" diff --git a/pymongo/synchronous/mongo_client.py b/pymongo/synchronous/mongo_client.py index 71ba6dd181..525b558ebb 100644 --- a/pymongo/synchronous/mongo_client.py +++ b/pymongo/synchronous/mongo_client.py @@ -471,6 +471,31 @@ def __init__( srvAllowedHostsSuffix=".internal.example.com", ) + - `srv_host_validator`: (callable or None) A callback used in place of the + default parent-domain check for hosts returned by SRV DNS records. It is + called once per returned host with the lowercased hostname as its only + argument, and must return ``True`` to accept the host or ``False`` to + reject it. Rejecting a host raises + :exc:`~pymongo.errors.ConfigurationError`, as does an exception raised by + the callback itself. Use this when the set of acceptable hosts cannot be + expressed as a single suffix:: + + def validator(host: str) -> bool: + return host.endswith((".a.example.com", ".b.example.com")) + + MongoClient( + "mongodb+srv://cluster.example.com/", + srv_host_validator=validator, + ) + + The callback must not block. It is mutually exclusive with + ``srvAllowedHostsSuffix``. Because this option is a callable, it can + only be passed in as a keyword argument, not through the connection string. + + .. warning:: + + This option replaces the built-in DNS spoofing safeguards. + Please use with caution. | **Write Concern options:** | (Only set if passed. No default values.) @@ -824,6 +849,7 @@ def __init__( srv_service_name = keyword_opts.get("srvservicename") srv_max_hosts = keyword_opts.get("srvmaxhosts") srv_allowed_hosts_suffix = keyword_opts.get("srvallowedhostssuffix") + srv_host_validator = keyword_opts.get("srv_host_validator") if len([h for h in self._host if "/" in h]) > 1: raise ConfigurationError("host must not contain multiple MongoDB URIs") for entity in self._host: @@ -916,7 +942,11 @@ def __init__( ) self._init_based_on_options( - self._seeds, srv_max_hosts, srv_service_name, srv_allowed_hosts_suffix + self._seeds, + srv_max_hosts, + srv_service_name, + srv_allowed_hosts_suffix, + srv_host_validator, ) self._opened = False @@ -936,6 +966,7 @@ def _resolve_srv(self) -> None: srv_service_name = keyword_opts.get("srvservicename") srv_max_hosts = keyword_opts.get("srvmaxhosts") srv_allowed_hosts_suffix = keyword_opts.get("srvallowedhostssuffix") + srv_host_validator = keyword_opts.get("srv_host_validator") for entity in self._host: # A hostname can only include a-z, 0-9, '-' and '.'. If we find a '/' # it must be a URI, @@ -957,6 +988,7 @@ def _resolve_srv(self) -> None: srv_service_name=srv_service_name, srv_max_hosts=srv_max_hosts, srv_allowed_hosts_suffix=srv_allowed_hosts_suffix, + srv_host_validator=srv_host_validator, ) seeds.update(res["nodelist"]) opts = res["options"] @@ -1001,7 +1033,11 @@ def _resolve_srv(self) -> None: ) self._init_based_on_options( - seeds, srv_max_hosts, srv_service_name, srv_allowed_hosts_suffix + seeds, + srv_max_hosts, + srv_service_name, + srv_allowed_hosts_suffix, + srv_host_validator, ) def _init_based_on_options( @@ -1010,7 +1046,17 @@ def _init_based_on_options( srv_max_hosts: Any, srv_service_name: Any, srv_allowed_hosts_suffix: Any, + srv_host_validator: Any = None, ) -> None: + if srv_host_validator is not None: + if srv_allowed_hosts_suffix is not None: + raise ConfigurationError( + "Cannot specify both srv_host_validator and srvAllowedHostsSuffix" + ) + if not self._resolve_srv_info["is_srv"]: + raise ConfigurationError( + "The srv_host_validator option is only allowed with 'mongodb+srv://' URIs" + ) self._event_listeners = self._options.pool_options._event_listeners self._topology_settings = TopologySettings( seeds=seeds, @@ -1029,6 +1075,7 @@ def _init_based_on_options( srv_service_name=srv_service_name, srv_max_hosts=srv_max_hosts, srv_allowed_hosts_suffix=srv_allowed_hosts_suffix, + srv_host_validator=srv_host_validator, server_monitoring_mode=self._options.server_monitoring_mode, topology_id=self._topology_settings._topology_id if self._topology_settings else None, ) diff --git a/pymongo/synchronous/monitor.py b/pymongo/synchronous/monitor.py index 9a25757f03..7b53f8b8df 100644 --- a/pymongo/synchronous/monitor.py +++ b/pymongo/synchronous/monitor.py @@ -365,6 +365,7 @@ def _get_seedlist(self) -> Optional[list[tuple[str, Any]]]: self._settings.pool_options.connect_timeout, self._settings.srv_service_name, srv_allowed_hosts_suffix=self._settings.srv_allowed_hosts_suffix, + srv_host_validator=self._settings.srv_host_validator, ) seedlist, ttl = resolver.get_hosts_and_min_ttl() if len(seedlist) == 0: diff --git a/pymongo/synchronous/settings.py b/pymongo/synchronous/settings.py index 2e8c6ddcd8..3416bbd130 100644 --- a/pymongo/synchronous/settings.py +++ b/pymongo/synchronous/settings.py @@ -18,7 +18,7 @@ import threading from collections.abc import Collection -from typing import Optional +from typing import Callable, Optional from bson.objectid import ObjectId from pymongo import common @@ -51,6 +51,7 @@ def __init__( srv_service_name: str = common.SRV_SERVICE_NAME, srv_max_hosts: int = 0, srv_allowed_hosts_suffix: Optional[str] = None, + srv_host_validator: Optional[Callable[[str], bool]] = None, server_monitoring_mode: str = common.SERVER_MONITORING_MODE, topology_id: Optional[ObjectId] = None, ): @@ -77,6 +78,7 @@ def __init__( srv_service_name, srv_max_hosts, srv_allowed_hosts_suffix, + srv_host_validator, server_monitoring_mode, topology_id, ) diff --git a/pymongo/synchronous/srv_resolver.py b/pymongo/synchronous/srv_resolver.py index 0e9745eb08..1d468490f4 100644 --- a/pymongo/synchronous/srv_resolver.py +++ b/pymongo/synchronous/srv_resolver.py @@ -18,9 +18,9 @@ import ipaddress import random -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, Callable, Optional, Union -from pymongo._psl import is_public_suffix +from pymongo._psl import SPECIAL_USE_LABELS, _to_punycode, is_public_suffix from pymongo.common import CONNECT_TIMEOUT from pymongo.errors import ConfigurationError @@ -64,21 +64,42 @@ def __init__( srv_service_name: str, srv_max_hosts: int = 0, srv_allowed_hosts_suffix: Optional[str] = None, + srv_host_validator: Optional[Callable[[str], bool]] = None, ): self.__fqdn = fqdn.lower() self.__srv = srv_service_name self.__connect_timeout = connect_timeout or CONNECT_TIMEOUT self.__srv_max_hosts = srv_max_hosts or 0 - self.__srv_allowed_hosts_suffix = ( - "." + srv_allowed_hosts_suffix.lower().strip(".") if srv_allowed_hosts_suffix else None - ) # ensure there's a . at the beginning of the domain - if self.__srv_allowed_hosts_suffix is not None and is_public_suffix( - self.__srv_allowed_hosts_suffix - ): + self.__srv_host_validator = srv_host_validator + # MongoClient rejects this combination earlier and with a better + # error, but parse_uri() reaches this constructor directly. Checking + # here too ensures srvAllowedHostsSuffix is never silently discarded. + if srv_host_validator is not None and srv_allowed_hosts_suffix is not None: raise ConfigurationError( - f"srvAllowedHostsSuffix must not be a public suffix, got: {srv_allowed_hosts_suffix}" + "Cannot specify both srv_host_validator and srvAllowedHostsSuffix" ) - # Validate the fully qualified domain name. + self.__srv_allowed_hosts_suffix = None + if srv_allowed_hosts_suffix is not None: + suffix = srv_allowed_hosts_suffix.strip(".") + if not suffix: + raise ConfigurationError( + f"srvAllowedHostsSuffix must not be empty, got: {srv_allowed_hosts_suffix!r}" + ) + suffix = _to_punycode(suffix).lower() + + is_special_use = suffix in SPECIAL_USE_LABELS + if len(suffix.split(".")) < 2 and not is_special_use: + raise ConfigurationError( + "srvAllowedHostsSuffix must contain at least two '.' separated labels, " + f"got: {srv_allowed_hosts_suffix}" + ) + + if not is_special_use and is_public_suffix(suffix): + raise ConfigurationError( + f"srvAllowedHostsSuffix must not be a public suffix, got: {srv_allowed_hosts_suffix}" + ) + self.__srv_allowed_hosts_suffix = "." + suffix + try: ipaddress.ip_address(fqdn) raise ConfigurationError(_INVALID_HOST_MSG % ("an IP address",)) @@ -133,14 +154,25 @@ def _get_srv_response_and_hosts( # Validate hosts for node in nodes: srv_host = node[0].lower() - if self.__fqdn == srv_host and self.nparts < 3: - raise ConfigurationError( - "Invalid SRV host: return address is identical to SRV hostname" - ) - if self.__srv_allowed_hosts_suffix is not None: + if self.__srv_host_validator is not None: + try: + allowed = self.__srv_host_validator(srv_host) + except Exception as exc: + raise ConfigurationError( + f"srv_host_validator raised an exception for SRV host {node[0]}: {exc}" + ) from exc + if not allowed: + raise ConfigurationError( + f"Invalid SRV host: {node[0]} was rejected by srv_host_validator" + ) + elif self.__srv_allowed_hosts_suffix is not None: if not srv_host.endswith(self.__srv_allowed_hosts_suffix): raise ConfigurationError(f"Invalid SRV host: {node[0]}") else: + if self.__fqdn == srv_host and self.nparts < 3: + raise ConfigurationError( + "Invalid SRV host: return address is identical to SRV hostname" + ) try: nlist = srv_host.split(".")[1:][-self.__slen :] except Exception as exc: diff --git a/pymongo/synchronous/uri_parser.py b/pymongo/synchronous/uri_parser.py index 9a6ab8c326..1e8b872f5b 100644 --- a/pymongo/synchronous/uri_parser.py +++ b/pymongo/synchronous/uri_parser.py @@ -17,7 +17,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any, Callable, Optional from urllib.parse import unquote_plus from pymongo.common import SRV_SERVICE_NAME, _CaseInsensitiveDictionary @@ -49,6 +49,7 @@ def parse_uri( srv_service_name: Optional[str] = None, srv_max_hosts: Optional[int] = None, srv_allowed_hosts_suffix: Optional[str] = None, + srv_host_validator: Optional[Callable[[str], bool]] = None, ) -> dict[str, Any]: """Parse and validate a MongoDB URI. @@ -118,6 +119,7 @@ def parse_uri( srv_service_name, srv_max_hosts, srv_allowed_hosts_suffix, + srv_host_validator, ) ) result["options"] = _make_options_case_sensitive(result["options"]) @@ -134,6 +136,7 @@ def _parse_srv( srv_service_name: Optional[str] = None, srv_max_hosts: Optional[int] = None, srv_allowed_hosts_suffix: Optional[str] = None, + srv_host_validator: Optional[Callable[[str], bool]] = None, ) -> dict[str, Any]: if uri.startswith(SCHEME): is_srv = False @@ -170,7 +173,12 @@ def _parse_srv( # argument overrides the same option passed in the connection string. connect_timeout = connect_timeout or options.get("connectTimeoutMS") dns_resolver = _SrvResolver( - fqdn, connect_timeout, srv_service_name, srv_max_hosts, srv_allowed_hosts_suffix + fqdn, + connect_timeout, + srv_service_name, + srv_max_hosts, + srv_allowed_hosts_suffix, + srv_host_validator, ) nodes = dns_resolver.get_hosts() dns_options = dns_resolver.get_options() diff --git a/test/asynchronous/test_dns.py b/test/asynchronous/test_dns.py index 6796ecff55..e6ed320740 100644 --- a/test/asynchronous/test_dns.py +++ b/test/asynchronous/test_dns.py @@ -209,30 +209,40 @@ async def test_connect_case_insensitive(self): class TestInitialDnsSeedlistDiscovery(AsyncPyMongoTestCase): """ Initial DNS Seedlist Discovery prose tests - https://github.com/mongodb/specifications/blob/0a7a8b5/source/initial-dns-seedlist-discovery/tests/README.md#prose-tests + https://github.com/mongodb/specifications/blob/d5719cd/source/initial-dns-seedlist-discovery/tests/README.md#prose-tests + + Numbered tests correspond to the numbered prose tests in the spec. The + unnumbered tests are PyMongo-specific additions with no spec counterpart. """ + async def _parse(self, srv_hostname, mock_target, **kwargs): + """Resolve mongodb+srv:// with SRV records naming mock_target.""" + with patch("dns.asyncresolver.resolve") as mock_resolver: + + async def mock_resolve(query, record_type, *args, **kwargs): + mock_srv = MagicMock() + # Mirror dnspython: the wire form keeps the root label, and the + # caller strips it via omit_final_dot. + mock_srv.target.to_text.side_effect = lambda omit_final_dot=False: ( + mock_target.rstrip(".") if omit_final_dot else mock_target + ) + return [mock_srv] + + mock_resolver.side_effect = mock_resolve + return await parse_uri(f"mongodb+srv://{srv_hostname}", **kwargs) + async def run_initial_dns_seedlist_discovery_prose_tests(self, test_cases): for case in test_cases: - with patch("dns.asyncresolver.resolve") as mock_resolver: - - async def mock_resolve(query, record_type, *args, **kwargs): - mock_srv = MagicMock() - mock_srv.target.to_text.return_value = case["mock_target"] - return [mock_srv] - - mock_resolver.side_effect = mock_resolve - domain = case["query"].split("._tcp.")[1] - connection_string = f"mongodb+srv://{domain}" - if "expected_error" not in case: - await parse_uri(connection_string) + domain = case["query"].split("._tcp.")[1] + if "expected_error" not in case: + await self._parse(domain, case["mock_target"]) + else: + try: + await self._parse(domain, case["mock_target"]) + except ConfigurationError as e: + self.assertIn(case["expected_error"], str(e)) else: - try: - await parse_uri(connection_string) - except ConfigurationError as e: - self.assertIn(case["expected_error"], str(e)) - else: - self.fail(f"ConfigurationError was not raised for query: {case['query']}") + self.fail(f"ConfigurationError was not raised for query: {case['query']}") async def test_1_allow_srv_hosts_with_fewer_than_three_dot_separated_parts(self): with patch("dns.asyncresolver.resolve"): @@ -296,7 +306,79 @@ async def test_4_throw_when_return_address_does_not_contain_dot_separating_share ] await self.run_initial_dns_seedlist_discovery_prose_tests(test_cases) - async def test_5_when_srv_hostname_has_three_or_more_dot_separated_parts_it_is_valid_for_the_returned_hostname_to_be_identical( + async def test_5_srv_host_validator_accepts_a_host_the_default_verification_would_reject(self): + # "blogs.evil.com" does not share a parent domain with the seed, so the + # default check rejects it; the callback overrides that decision. + res = await self._parse( + "blogs.mongodb.com", "blogs.evil.com", srv_host_validator=lambda host: True + ) + self.assertEqual(["blogs.evil.com"], [node[0] for node in res["nodelist"]]) + + async def test_6_reject_a_host_the_default_verification_would_accept(self): + with self.assertRaisesRegex(ConfigurationError, "rejected by srv_host_validator"): + await self._parse( + "blogs.mongodb.com", "cluster.mongodb.com", srv_host_validator=lambda host: False + ) + + async def test_7_the_validator_receives_the_normalized_host_name(self): + seen = [] + + def validator(host): + seen.append(host) + return True + + await self._parse("blogs.mongodb.com", "CLUSTER.MONGODB.COM.", srv_host_validator=validator) + self.assertEqual(["cluster.mongodb.com"], seen) + + async def test_8_wrap_an_error_raised_by_the_validator(self): + def validator(host): + raise RuntimeError("boom") + + with self.assertRaisesRegex(ConfigurationError, "srv_host_validator raised an exception"): + await self._parse( + "blogs.mongodb.com", "cluster.mongodb.com", srv_host_validator=validator + ) + + async def test_9_throw_when_both_srv_allowed_hosts_suffix_and_srv_host_validator_are_configured( + self, + ): + # Rejected by the client + with self.assertRaisesRegex(ConfigurationError, "Cannot specify both"): + self.simple_client( + "mongodb+srv://blogs.mongodb.com", + srv_host_validator=lambda host: True, + srvAllowedHostsSuffix=".mongodb.com", + connect=False, + ) + + # Rejected by the resolver + with self.assertRaisesRegex(ConfigurationError, "Cannot specify both"): + await self._parse( + "blogs.mongodb.com", + "cluster.mongodb.com", + srv_host_validator=lambda host: True, + srv_allowed_hosts_suffix=".mongodb.com", + ) + + async def test_10_accept_a_mixed_case_returned_address_with_srv_allowed_hosts_suffix(self): + # Returned addresses are normalized before the suffix comparison, so + # the case DNS happens to use must not affect the result. + res = await self._parse( + "blogs.mongodb.com", "CLUSTER.MONGODB.COM.", srv_allowed_hosts_suffix=".mongodb.com" + ) + self.assertEqual(["cluster.mongodb.com"], [node[0] for node in res["nodelist"]]) + + async def test_11_throw_when_srv_host_validator_is_not_callable(self): + with self.assertRaisesRegex(ValueError, "must be a callable"): + self.simple_client("mongodb+srv://blogs.mongodb.com", srv_host_validator="notacallable") + + async def test_validator_replaces_the_identical_hostname_check(self): + # The callback is the complete verdict, so a permissive validator accepts + # a returned address the default check would reject as identical. + res = await self._parse("mongo.local", "mongo.local", srv_host_validator=lambda host: True) + self.assertEqual(["mongo.local"], [node[0] for node in res["nodelist"]]) + + async def test_srv_hostname_with_three_or_more_parts_may_equal_the_returned_hostname( self, ): test_cases = [ diff --git a/test/asynchronous/test_srv_polling.py b/test/asynchronous/test_srv_polling.py index b8b9129925..7516af9ffc 100644 --- a/test/asynchronous/test_srv_polling.py +++ b/test/asynchronous/test_srv_polling.py @@ -359,6 +359,65 @@ def nodelist_callback(): with SrvPollingKnobs(nodelist_callback=nodelist_callback): await self.assert_nodelist_change(response, client) + async def test_14_the_validator_is_consulted_when_srv_records_are_rescanned(self): + seen = [] + + def validator(host): + seen.append(host) + return True + + response = self.BASE_SRV_RESPONSE[:] + response.append(("localhost.test.build.10gen.cc", 27019)) + + with SrvPollingKnobs(ttl_time=WAIT_TIME, min_srv_rescan_interval=WAIT_TIME): + client = self.simple_client(self.CONNECTION_STRING, srv_host_validator=validator) + await client.aconnect() + await self.assert_nodelist_change(self.BASE_SRV_RESPONSE, client) + seen.clear() + with SrvPollingKnobs(nodelist_callback=lambda: response): + await self.assert_nodelist_change(response, client) + + self.assertIn("localhost.test.build.10gen.cc", seen) + + async def test_15_a_rejecting_or_raising_validator_does_not_raise_or_stop_polling(self): + def rejecting(host): + return False + + def raising(host): + raise RuntimeError("boom") + + response = self.BASE_SRV_RESPONSE[:] + response.append(("localhost.test.build.10gen.cc", 27019)) + + for failing in (rejecting, raising): + with self.subTest(validator=failing.__name__): + # Accept everything until the client is connected, then start failing. + state = {"validator": lambda host: True} + + with SrvPollingKnobs(ttl_time=WAIT_TIME, min_srv_rescan_interval=WAIT_TIME): + client = self.simple_client( + self.CONNECTION_STRING, + srv_host_validator=lambda host: state["validator"](host), + ) + await client.aconnect() + await self.assert_nodelist_change(self.BASE_SRV_RESPONSE, client) + + # The rescan sees the new record but the validator fails: no + # error reaches the application and the topology is unchanged. + state["validator"] = failing + with SrvPollingKnobs( + nodelist_callback=lambda: response, count_resolver_calls=True + ): + await self.assert_nodelist_nochange(self.BASE_SRV_RESPONSE, client) + + # Polling was not stopped by the failures: once the validator + # accepts again, the new host is picked up. + state["validator"] = lambda host: True + with SrvPollingKnobs(nodelist_callback=lambda: response): + await self.assert_nodelist_change(response, client) + + await client.close() + async def test_srv_waits_to_poll(self): modified = [("localhost.test.build.10gen.cc", 27019)] diff --git a/test/test_dns.py b/test/test_dns.py index e631420643..cef1f735c2 100644 --- a/test/test_dns.py +++ b/test/test_dns.py @@ -207,30 +207,40 @@ def test_connect_case_insensitive(self): class TestInitialDnsSeedlistDiscovery(PyMongoTestCase): """ Initial DNS Seedlist Discovery prose tests - https://github.com/mongodb/specifications/blob/0a7a8b5/source/initial-dns-seedlist-discovery/tests/README.md#prose-tests + https://github.com/mongodb/specifications/blob/d5719cd/source/initial-dns-seedlist-discovery/tests/README.md#prose-tests + + Numbered tests correspond to the numbered prose tests in the spec. The + unnumbered tests are PyMongo-specific additions with no spec counterpart. """ + def _parse(self, srv_hostname, mock_target, **kwargs): + """Resolve mongodb+srv:// with SRV records naming mock_target.""" + with patch("dns.resolver.resolve") as mock_resolver: + + def mock_resolve(query, record_type, *args, **kwargs): + mock_srv = MagicMock() + # Mirror dnspython: the wire form keeps the root label, and the + # caller strips it via omit_final_dot. + mock_srv.target.to_text.side_effect = lambda omit_final_dot=False: ( + mock_target.rstrip(".") if omit_final_dot else mock_target + ) + return [mock_srv] + + mock_resolver.side_effect = mock_resolve + return parse_uri(f"mongodb+srv://{srv_hostname}", **kwargs) + def run_initial_dns_seedlist_discovery_prose_tests(self, test_cases): for case in test_cases: - with patch("dns.resolver.resolve") as mock_resolver: - - def mock_resolve(query, record_type, *args, **kwargs): - mock_srv = MagicMock() - mock_srv.target.to_text.return_value = case["mock_target"] - return [mock_srv] - - mock_resolver.side_effect = mock_resolve - domain = case["query"].split("._tcp.")[1] - connection_string = f"mongodb+srv://{domain}" - if "expected_error" not in case: - parse_uri(connection_string) + domain = case["query"].split("._tcp.")[1] + if "expected_error" not in case: + self._parse(domain, case["mock_target"]) + else: + try: + self._parse(domain, case["mock_target"]) + except ConfigurationError as e: + self.assertIn(case["expected_error"], str(e)) else: - try: - parse_uri(connection_string) - except ConfigurationError as e: - self.assertIn(case["expected_error"], str(e)) - else: - self.fail(f"ConfigurationError was not raised for query: {case['query']}") + self.fail(f"ConfigurationError was not raised for query: {case['query']}") def test_1_allow_srv_hosts_with_fewer_than_three_dot_separated_parts(self): with patch("dns.resolver.resolve"): @@ -294,7 +304,77 @@ def test_4_throw_when_return_address_does_not_contain_dot_separating_shared_part ] self.run_initial_dns_seedlist_discovery_prose_tests(test_cases) - def test_5_when_srv_hostname_has_three_or_more_dot_separated_parts_it_is_valid_for_the_returned_hostname_to_be_identical( + def test_5_srv_host_validator_accepts_a_host_the_default_verification_would_reject(self): + # "blogs.evil.com" does not share a parent domain with the seed, so the + # default check rejects it; the callback overrides that decision. + res = self._parse( + "blogs.mongodb.com", "blogs.evil.com", srv_host_validator=lambda host: True + ) + self.assertEqual(["blogs.evil.com"], [node[0] for node in res["nodelist"]]) + + def test_6_reject_a_host_the_default_verification_would_accept(self): + with self.assertRaisesRegex(ConfigurationError, "rejected by srv_host_validator"): + self._parse( + "blogs.mongodb.com", "cluster.mongodb.com", srv_host_validator=lambda host: False + ) + + def test_7_the_validator_receives_the_normalized_host_name(self): + seen = [] + + def validator(host): + seen.append(host) + return True + + self._parse("blogs.mongodb.com", "CLUSTER.MONGODB.COM.", srv_host_validator=validator) + self.assertEqual(["cluster.mongodb.com"], seen) + + def test_8_wrap_an_error_raised_by_the_validator(self): + def validator(host): + raise RuntimeError("boom") + + with self.assertRaisesRegex(ConfigurationError, "srv_host_validator raised an exception"): + self._parse("blogs.mongodb.com", "cluster.mongodb.com", srv_host_validator=validator) + + def test_9_throw_when_both_srv_allowed_hosts_suffix_and_srv_host_validator_are_configured( + self, + ): + # Rejected by the client + with self.assertRaisesRegex(ConfigurationError, "Cannot specify both"): + self.simple_client( + "mongodb+srv://blogs.mongodb.com", + srv_host_validator=lambda host: True, + srvAllowedHostsSuffix=".mongodb.com", + connect=False, + ) + + # Rejected by the resolver + with self.assertRaisesRegex(ConfigurationError, "Cannot specify both"): + self._parse( + "blogs.mongodb.com", + "cluster.mongodb.com", + srv_host_validator=lambda host: True, + srv_allowed_hosts_suffix=".mongodb.com", + ) + + def test_10_accept_a_mixed_case_returned_address_with_srv_allowed_hosts_suffix(self): + # Returned addresses are normalized before the suffix comparison, so + # the case DNS happens to use must not affect the result. + res = self._parse( + "blogs.mongodb.com", "CLUSTER.MONGODB.COM.", srv_allowed_hosts_suffix=".mongodb.com" + ) + self.assertEqual(["cluster.mongodb.com"], [node[0] for node in res["nodelist"]]) + + def test_11_throw_when_srv_host_validator_is_not_callable(self): + with self.assertRaisesRegex(ValueError, "must be a callable"): + self.simple_client("mongodb+srv://blogs.mongodb.com", srv_host_validator="notacallable") + + def test_validator_replaces_the_identical_hostname_check(self): + # The callback is the complete verdict, so a permissive validator accepts + # a returned address the default check would reject as identical. + res = self._parse("mongo.local", "mongo.local", srv_host_validator=lambda host: True) + self.assertEqual(["mongo.local"], [node[0] for node in res["nodelist"]]) + + def test_srv_hostname_with_three_or_more_parts_may_equal_the_returned_hostname( self, ): test_cases = [ diff --git a/test/test_srv_polling.py b/test/test_srv_polling.py index e01ae1da7f..0295616044 100644 --- a/test/test_srv_polling.py +++ b/test/test_srv_polling.py @@ -359,6 +359,65 @@ def nodelist_callback(): with SrvPollingKnobs(nodelist_callback=nodelist_callback): self.assert_nodelist_change(response, client) + def test_14_the_validator_is_consulted_when_srv_records_are_rescanned(self): + seen = [] + + def validator(host): + seen.append(host) + return True + + response = self.BASE_SRV_RESPONSE[:] + response.append(("localhost.test.build.10gen.cc", 27019)) + + with SrvPollingKnobs(ttl_time=WAIT_TIME, min_srv_rescan_interval=WAIT_TIME): + client = self.simple_client(self.CONNECTION_STRING, srv_host_validator=validator) + client._connect() + self.assert_nodelist_change(self.BASE_SRV_RESPONSE, client) + seen.clear() + with SrvPollingKnobs(nodelist_callback=lambda: response): + self.assert_nodelist_change(response, client) + + self.assertIn("localhost.test.build.10gen.cc", seen) + + def test_15_a_rejecting_or_raising_validator_does_not_raise_or_stop_polling(self): + def rejecting(host): + return False + + def raising(host): + raise RuntimeError("boom") + + response = self.BASE_SRV_RESPONSE[:] + response.append(("localhost.test.build.10gen.cc", 27019)) + + for failing in (rejecting, raising): + with self.subTest(validator=failing.__name__): + # Accept everything until the client is connected, then start failing. + state = {"validator": lambda host: True} + + with SrvPollingKnobs(ttl_time=WAIT_TIME, min_srv_rescan_interval=WAIT_TIME): + client = self.simple_client( + self.CONNECTION_STRING, + srv_host_validator=lambda host: state["validator"](host), + ) + client._connect() + self.assert_nodelist_change(self.BASE_SRV_RESPONSE, client) + + # The rescan sees the new record but the validator fails: no + # error reaches the application and the topology is unchanged. + state["validator"] = failing + with SrvPollingKnobs( + nodelist_callback=lambda: response, count_resolver_calls=True + ): + self.assert_nodelist_nochange(self.BASE_SRV_RESPONSE, client) + + # Polling was not stopped by the failures: once the validator + # accepts again, the new host is picked up. + state["validator"] = lambda host: True + with SrvPollingKnobs(nodelist_callback=lambda: response): + self.assert_nodelist_change(response, client) + + client.close() + def test_srv_waits_to_poll(self): modified = [("localhost.test.build.10gen.cc", 27019)]