Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2cd02da
add srvAllowedHostsSuffix option to srv uri
sleepyStick Jun 11, 2026
499a3ec
Merge branch 'master' into PYTHON-5814
sleepyStick Jun 11, 2026
3423d39
sync unified tests
sleepyStick Jun 16, 2026
3e30434
Merge branch 'master' into PYTHON-5814
sleepyStick Jun 16, 2026
1ac4967
add unified test (forgot to commit this previously oops)
sleepyStick Jun 17, 2026
a8a3b4e
Merge branch 'master' into PYTHON-5814
sleepyStick Jun 17, 2026
9663485
Merge branch 'master' into PYTHON-5814
sleepyStick Jun 23, 2026
a71dd7a
Merge branch 'master' into PYTHON-5814
sleepyStick Jun 24, 2026
4a6ba01
add more tests and edit docstring
sleepyStick Jun 29, 2026
466a47e
edit changelog
sleepyStick Jun 29, 2026
7902127
cache public suffix list after first load
sleepyStick Jun 30, 2026
c8f8c9f
add example to docstring
sleepyStick Jun 30, 2026
2c8ad29
Merge branch 'main' into PYTHON-5814
sleepyStick Aug 11, 2026
14ae604
add psl tests
sleepyStick Aug 12, 2026
1fc7fe2
remove two label minimum
sleepyStick Aug 25, 2026
fa34923
add test
sleepyStick Aug 25, 2026
aaad72f
Merge branch 'main' into PYTHON-5814
sleepyStick Aug 25, 2026
a2c5cbd
lower the srv response
sleepyStick Aug 27, 2026
071a285
replaced this test with a different one in the spec repo -- this one …
sleepyStick Aug 27, 2026
63d57ca
add psl to resync spec script so it can be synced regularly and sync …
sleepyStick Aug 27, 2026
7c4e57a
Merge branch 'main' into PYTHON-5814
sleepyStick Aug 27, 2026
ae72df1
update changelog
sleepyStick Aug 27, 2026
c950d35
Merge branch 'PYTHON-5814' of github.com:sleepyStick/mongo-python-dri…
sleepyStick Aug 27, 2026
8f58480
NS feedback
sleepyStick Aug 27, 2026
820c648
Merge branch 'main' into PYTHON-5814
sleepyStick Aug 27, 2026
d99b4e9
sort the list alphabetically
sleepyStick Aug 27, 2026
684f31b
add test and convert all strings to punycode
sleepyStick Aug 29, 2026
44283f2
pre-convert to punycode
sleepyStick Aug 31, 2026
7a6c2e8
Merge branch 'main' into PYTHON-5814
sleepyStick Aug 31, 2026
46efa90
fix srv_allow_hosts_suffix in settings shared
sleepyStick Aug 31, 2026
c949f54
resync specs
sleepyStick Sep 2, 2026
8fb7ded
PYTHON-5814 add srvHostValidator
sleepyStick Sep 11, 2026
46f8c83
Merge branch 'main' into PYTHON-5814
sleepyStick Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`_).
Expand Down
20 changes: 20 additions & 0 deletions pymongo/_psl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
51 changes: 49 additions & 2 deletions pymongo/asynchronous/mongo_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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"]
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down
1 change: 1 addition & 0 deletions pymongo/asynchronous/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion pymongo/asynchronous/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
):
Expand All @@ -77,6 +78,7 @@ def __init__(
srv_service_name,
srv_max_hosts,
srv_allowed_hosts_suffix,
srv_host_validator,
server_monitoring_mode,
topology_id,
)
62 changes: 47 additions & 15 deletions pymongo/asynchronous/srv_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",))
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 10 additions & 2 deletions pymongo/asynchronous/uri_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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"])
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions pymongo/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading