Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
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
18 changes: 18 additions & 0 deletions .evergreen/resync-specs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ cpjson () {

}

# Copy the Public Suffix List bundled with the driver from the specs repo.
# Unlike the spec tests, this is driver source, so it lives in pymongo/ rather
# than test/.
cp_psl () {
local src="$SPECS/source/public-suffix-list/public_suffix_list.dat"
if ! [ -f "$src" ]
then
echo "Could not find the public suffix list at $src" >&2
return 1
fi
cp "$src" "$PYMONGO"/pymongo/public_suffix_list.dat
}

for spec in "$@"
do
# Match the spec dir name, the python test dir name, and/or common abbreviations.
Expand Down Expand Up @@ -147,6 +160,11 @@ do
;;
srv|SRV|initial-dns-seedlist-discovery|srv_seedlist)
cpjson initial-dns-seedlist-discovery/tests/ srv_seedlist
# srvAllowedHostsSuffix validation uses the bundled Public Suffix List.
cp_psl

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this will resync every time there is a change to the Public Suffix List?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it syncs from the spec's copy of the PSL -- which is automatically updated once a month but could be manually triggered (in the specs repo)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(the actual PSL has like an avg of 3 changes per week which is far too much noise to always keep up to date)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And our automated weekly sync will pick up that monthly change in the spec repo?

;;
psl|public-suffix-list|public_suffix_list)
cp_psl
;;
read-write-concern|read_write_concern)
cpjson read-write-concern/tests/operation read_write_concern/operation
Expand Down
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ repos:
# - test/versioned-api/crud-api-version-1-strict.json:514: nin ==> inn, min, bin, nine
# - test/test_client.py:188: te ==> the, be, we, to
args: ["-L", "fle,fo,infinit,isnt,nin,te,aks"]
exclude: ^pymongo/public_suffix_list\.dat$

- repo: local
hooks:
Expand Down
11 changes: 11 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ Changes in Version 4.18.0 (2026/XX/XX)

PyMongo 4.18 brings a number of changes including:

- Added ``srvAllowedHostsSuffix`` as a URI option and keyword argument to
:class:`~pymongo.synchronous.mongo_client.MongoClient` and
:class:`~pymongo.asynchronous.mongo_client.AsyncMongoClient`. When connecting
via ``mongodb+srv://``, this option overrides the default requirement that
SRV-returned hosts share the same parent domain as the seed hostname,
allowing hosts under a different domain suffix to be accepted. The suffix must
not be a public suffix (per the `Public Suffix List
<https://publicsuffix.org/list/>`_). See the
:class:`~pymongo.synchronous.mongo_client.MongoClient` and
:class:`~pymongo.asynchronous.mongo_client.AsyncMongoClient` documentation for
security considerations.
- Dropped support for MongoDB 4.2.
- Added support for MongoDB 9.0.
- Improved TLS connection performance by reusing TLS sessions across connections
Expand Down
61 changes: 61 additions & 0 deletions pymongo/_psl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Copyright 2024-present MongoDB, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you
# may not use this file except in compliance with the License. You
# may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the License for the specific language governing
# permissions and limitations under the License.

"""Public Suffix List lookup for srvAllowedHostsSuffix validation."""

from __future__ import annotations

from pathlib import Path
from typing import Optional

_PUBLIC_SUFFIXES: Optional[tuple[set[str], set[str], set[str]]] = None


def _load_public_suffixes() -> tuple[set[str], set[str], set[str]]:
path = Path(__file__).parent / "public_suffix_list.dat"
suffixes: set[str] = set()
wildcards: set[str] = set()
exceptions: set[str] = set()
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip() # noqa: PLW2901
if not line or line.startswith("//"):
continue
if line.startswith("!"):
exceptions.add(line[1:].lower())
elif line.startswith("*."):
wildcards.add(line[2:].lower())
else:
suffixes.add(line.lower())
return suffixes, wildcards, exceptions


def is_public_suffix(domain: str) -> bool:
"""Return True if domain is a public suffix per the bundled Public Suffix List."""
global _PUBLIC_SUFFIXES # noqa: PLW0603
if _PUBLIC_SUFFIXES is None:
_PUBLIC_SUFFIXES = _load_public_suffixes()
suffixes, wildcards, exceptions = _PUBLIC_SUFFIXES

domain = domain.lower().strip(".")
if domain in exceptions:
return False
if domain in suffixes:
return True
parts = domain.split(".")
# this logic is to handle the wildcard rule, the domain could still be a public suffix if:
# - either `parts` is a single label, and thus it is a public suffix list (per the `*`) rule
# - or another wildcard rule such as *.xyz exists (stored as just xyz in `wildcards`), thus we check
# if `parts[1:]` is in the list of wildcard rules.
return len(parts) == 1 or (len(parts) > 1 and ".".join(parts[1:]) in wildcards)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a short comment explaining this line? I'm not following how this results in a suffix being public or not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i assume "this line" is the return line, and if so, done! lmk if the comment isn't clear tho

46 changes: 43 additions & 3 deletions pymongo/asynchronous/mongo_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,30 @@ def __init__(
connect to. More specifically, when a "mongodb+srv://" connection string
resolves to more than srvMaxHosts number of hosts, the client will randomly
choose an srvMaxHosts sized subset of hosts.
- `srvAllowedHostsSuffix`: (string) Overrides the default requirement that
hosts returned by SRV DNS records share the same parent domain as the seed
hostname. When set, the driver accepts any returned host whose name ends
with this suffix (e.g. ``".atlas.mongodb.com"``). The value must not be a
public suffix (per the Public Suffix List). Only valid with
``mongodb+srv://`` URIs.

.. warning::

This option relaxes a built-in DNS spoofing safeguard. Use the most
specific suffix possible for your deployment rather than a broad
company-wide domain. For example, instead of::

AsyncMongoClient(
"mongodb+srv://cluster.test.internal.example.com/",
srvAllowedHostsSuffix=".example.com",
)

which would accept any host across the entire domain, scope it further like so::

AsyncMongoClient(
"mongodb+srv://cluster.test.internal.example.com/",
srvAllowedHostsSuffix=".internal.example.com",
)


| **Write Concern options:**
Expand Down Expand Up @@ -799,6 +823,7 @@ def __init__(
fqdn = None
srv_service_name = keyword_opts.get("srvservicename")
srv_max_hosts = keyword_opts.get("srvmaxhosts")
srv_allowed_hosts_suffix = keyword_opts.get("srvallowedhostssuffix")
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 @@ -849,6 +874,8 @@ def __init__(
srv_service_name = opts.get("srvServiceName", common.SRV_SERVICE_NAME)

srv_max_hosts = srv_max_hosts or opts.get("srvmaxhosts")
if srv_allowed_hosts_suffix is None:
srv_allowed_hosts_suffix = opts.get("srvallowedhostssuffix")
opts = self._normalize_and_validate_options(opts, self._seeds)

# Username and password passed as kwargs override user info in URI.
Expand Down Expand Up @@ -886,7 +913,9 @@ def __init__(

self._retry_policy = _RetryPolicy(attempts=self._options.max_adaptive_retries)

self._init_based_on_options(self._seeds, srv_max_hosts, srv_service_name)
self._init_based_on_options(
self._seeds, srv_max_hosts, srv_service_name, srv_allowed_hosts_suffix
)

self._opened = False
self._closed = False
Expand All @@ -904,6 +933,7 @@ async def _resolve_srv(self) -> None:
opts = common._CaseInsensitiveDictionary()
srv_service_name = keyword_opts.get("srvservicename")
srv_max_hosts = keyword_opts.get("srvmaxhosts")
srv_allowed_hosts_suffix = keyword_opts.get("srvallowedhostssuffix")
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 @@ -924,6 +954,7 @@ async def _resolve_srv(self) -> None:
connect_timeout=timeout,
srv_service_name=srv_service_name,
srv_max_hosts=srv_max_hosts,
srv_allowed_hosts_suffix=srv_allowed_hosts_suffix,
)
seeds.update(res["nodelist"])
opts = res["options"]
Expand Down Expand Up @@ -956,6 +987,8 @@ async def _resolve_srv(self) -> None:
srv_service_name = opts.get("srvServiceName", common.SRV_SERVICE_NAME)

srv_max_hosts = srv_max_hosts or opts.get("srvmaxhosts")
if srv_allowed_hosts_suffix is None:
srv_allowed_hosts_suffix = opts.get("srvAllowedHostsSuffix")
opts = self._normalize_and_validate_options(opts, seeds)

# Username and password passed as kwargs override user info in URI.
Expand All @@ -965,10 +998,16 @@ async def _resolve_srv(self) -> None:
username, password, self._resolve_srv_info["dbase"], opts, _IS_SYNC
)

self._init_based_on_options(seeds, srv_max_hosts, srv_service_name)
self._init_based_on_options(
seeds, srv_max_hosts, srv_service_name, srv_allowed_hosts_suffix
)

def _init_based_on_options(
self, seeds: Collection[tuple[str, int]], srv_max_hosts: Any, srv_service_name: Any
self,
seeds: Collection[tuple[str, int]],
srv_max_hosts: Any,
srv_service_name: Any,
srv_allowed_hosts_suffix: Any,
) -> None:
self._event_listeners = self._options.pool_options._event_listeners
self._topology_settings = TopologySettings(
Expand All @@ -987,6 +1026,7 @@ def _init_based_on_options(
load_balanced=self._options.load_balanced,
srv_service_name=srv_service_name,
srv_max_hosts=srv_max_hosts,
srv_allowed_hosts_suffix=srv_allowed_hosts_suffix,
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 @@ -366,6 +366,7 @@ async def _get_seedlist(self) -> Optional[list[tuple[str, Any]]]:
self._fqdn,
self._settings.pool_options.connect_timeout,
self._settings.srv_service_name,
srv_allowed_hosts_suffix=self._settings.srv_allowed_hosts_suffix,
)
seedlist, ttl = await resolver.get_hosts_and_min_ttl()
if len(seedlist) == 0:
Expand Down
7 changes: 7 additions & 0 deletions pymongo/asynchronous/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def __init__(
load_balanced: Optional[bool] = None,
srv_service_name: str = common.SRV_SERVICE_NAME,
srv_max_hosts: int = 0,
srv_allowed_hosts_suffix: Optional[str] = None,
server_monitoring_mode: str = common.SERVER_MONITORING_MODE,
topology_id: Optional[ObjectId] = None,
):
Expand Down Expand Up @@ -79,6 +80,7 @@ def __init__(
self._load_balanced = load_balanced
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._server_monitoring_mode = server_monitoring_mode
if topology_id is not None:
self._topology_id = topology_id
Expand Down Expand Up @@ -156,6 +158,11 @@ def srv_max_hosts(self) -> int:
"""The srvMaxHosts."""
return self._srv_max_hosts

@property
def srv_allowed_hosts_suffix(self) -> Optional[str]:
"""The srvAllowedHostsSuffix."""
return self._srv_allowed_hosts_suffix

@property
def server_monitoring_mode(self) -> str:
"""The serverMonitoringMode."""
Expand Down
31 changes: 23 additions & 8 deletions pymongo/asynchronous/srv_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import random
from typing import TYPE_CHECKING, Any, Optional, Union

from pymongo._psl import is_public_suffix
from pymongo.common import CONNECT_TIMEOUT
from pymongo.errors import ConfigurationError

Expand Down Expand Up @@ -71,11 +72,21 @@ def __init__(
connect_timeout: Optional[float],
srv_service_name: str,
srv_max_hosts: int = 0,
srv_allowed_hosts_suffix: Optional[str] = None,
):
self.__fqdn = fqdn
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
):
raise ConfigurationError(
f"srvAllowedHostsSuffix must not be a public suffix, got: {srv_allowed_hosts_suffix}"
)
# Validate the fully qualified domain name.
try:
ipaddress.ip_address(fqdn)
Expand Down Expand Up @@ -124,7 +135,7 @@ async def _get_srv_response_and_hosts(

# Construct address tuples
nodes = [
(maybe_decode(res.target.to_text(omit_final_dot=True)), res.port) # type: ignore[attr-defined]
(maybe_decode(res.target.to_text(omit_final_dot=True)).lower(), res.port) # type: ignore[attr-defined]
for res in results
]

Expand All @@ -135,12 +146,16 @@ async def _get_srv_response_and_hosts(
raise ConfigurationError(
"Invalid SRV host: return address is identical to SRV hostname"
)
try:
nlist = srv_host.split(".")[1:][-self.__slen :]
except Exception as exc:
raise ConfigurationError(f"Invalid SRV host: {node[0]}") from exc
if self.__plist != nlist:
raise ConfigurationError(f"Invalid SRV host: {node[0]}")
if 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:
try:
nlist = srv_host.split(".")[1:][-self.__slen :]
except Exception as exc:
raise ConfigurationError(f"Invalid SRV host: {node[0]}") from exc
if self.__plist != nlist:
raise ConfigurationError(f"Invalid SRV host: {node[0]}")
if self.__srv_max_hosts:
nodes = random.sample(nodes, min(self.__srv_max_hosts, len(nodes)))
return results, nodes
Expand Down
8 changes: 7 additions & 1 deletion pymongo/asynchronous/uri_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ async def parse_uri(
connect_timeout: Optional[float] = None,
srv_service_name: Optional[str] = None,
srv_max_hosts: Optional[int] = None,
srv_allowed_hosts_suffix: Optional[str] = None,
) -> dict[str, Any]:
"""Parse and validate a MongoDB URI.

Expand Down Expand Up @@ -116,6 +117,7 @@ async def parse_uri(
connect_timeout,
srv_service_name,
srv_max_hosts,
srv_allowed_hosts_suffix,
)
)
result["options"] = _make_options_case_sensitive(result["options"])
Expand All @@ -131,6 +133,7 @@ async def _parse_srv(
connect_timeout: Optional[float] = None,
srv_service_name: Optional[str] = None,
srv_max_hosts: Optional[int] = None,
srv_allowed_hosts_suffix: Optional[str] = None,
) -> dict[str, Any]:
if uri.startswith(SCHEME):
is_srv = False
Expand Down Expand Up @@ -158,14 +161,17 @@ async def _parse_srv(

hosts = unquote_plus(hosts)
srv_max_hosts = srv_max_hosts or options.get("srvMaxHosts")
srv_allowed_hosts_suffix = srv_allowed_hosts_suffix or options.get("srvAllowedHostsSuffix")
if is_srv:
nodes = split_hosts(hosts, default_port=None)
fqdn, _port = nodes[0]

# Use the connection timeout. connectTimeoutMS passed as a keyword
# 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)
dns_resolver = _SrvResolver(
fqdn, connect_timeout, srv_service_name, srv_max_hosts, srv_allowed_hosts_suffix
)
nodes = await dns_resolver.get_hosts()
dns_options = await dns_resolver.get_options()
if dns_options:
Expand Down
1 change: 1 addition & 0 deletions pymongo/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@ def validate_server_monitoring_mode(option: str, value: str) -> str:
"zlibcompressionlevel": validate_zlib_compression_level,
"srvservicename": validate_string,
"srvmaxhosts": validate_non_negative_integer,
"srvallowedhostssuffix": validate_string,
"timeoutms": validate_timeoutms,
"servermonitoringmode": validate_server_monitoring_mode,
"maxadaptiveretries": validate_non_negative_integer,
Expand Down
Loading
Loading