From 1017bcb32d8f7885a908494f7fe96b199aba4083 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 24 Sep 2026 12:19:36 -0400 Subject: [PATCH 1/5] Merge commit from fork * SECBUG-4278 Don't decode percent-encoded delimiters in URI hosts _validate_uri and _parse_srv both called unquote_plus on the whole host section before splitting it into individual host:port entries. A percent-encoded "," or ":" in an attacker-influenced hostname fragment therefore decoded into a real delimiter, injecting an extra, attacker-chosen host and port into the seed list on every parse. Move the percent-decoding into split_hosts and apply it only to Unix domain socket paths (identified by their unescaped ".sock" suffix before decoding), the only host identifiers that legitimately need it, and only after splitting on ",". * PYTHON-5986 Reject percent-encoded hostnames, preserve IPv6 zone indexes An IPv6 zone index is escaped as %25 (RFC 6874), so sockets are not the only host identifier that legitimately requires decoding; decode those too. Any other percent-encoding in a hostname is now rejected with InvalidURI rather than passed through as an unresolvable host. * PYTHON-5986 Address review feedback on test names and comments * PYTHON-5986 Simplify test comments * PYTHON-5986 Remove changelog entry --- pymongo/asynchronous/uri_parser.py | 2 -- pymongo/synchronous/uri_parser.py | 2 -- pymongo/uri_parser_shared.py | 18 +++++++++++++++--- test/test_uri_parser.py | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/pymongo/asynchronous/uri_parser.py b/pymongo/asynchronous/uri_parser.py index 235e7e1dd3..24d3427843 100644 --- a/pymongo/asynchronous/uri_parser.py +++ b/pymongo/asynchronous/uri_parser.py @@ -18,7 +18,6 @@ from __future__ import annotations from typing import Any, Optional -from urllib.parse import unquote_plus from pymongo.asynchronous.srv_resolver import _SrvResolver from pymongo.common import SRV_SERVICE_NAME, _CaseInsensitiveDictionary @@ -159,7 +158,6 @@ async def _parse_srv( else: hosts = host_part - 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: diff --git a/pymongo/synchronous/uri_parser.py b/pymongo/synchronous/uri_parser.py index 9a6ab8c326..e4f521e3a9 100644 --- a/pymongo/synchronous/uri_parser.py +++ b/pymongo/synchronous/uri_parser.py @@ -18,7 +18,6 @@ from __future__ import annotations from typing import Any, Optional -from urllib.parse import unquote_plus from pymongo.common import SRV_SERVICE_NAME, _CaseInsensitiveDictionary from pymongo.errors import ConfigurationError, InvalidURI @@ -159,7 +158,6 @@ def _parse_srv( else: hosts = host_part - 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: diff --git a/pymongo/uri_parser_shared.py b/pymongo/uri_parser_shared.py index f4ee752d30..5e34c2d702 100644 --- a/pymongo/uri_parser_shared.py +++ b/pymongo/uri_parser_shared.py @@ -450,10 +450,23 @@ def split_hosts(hosts: str, default_port: Optional[int] = DEFAULT_PORT) -> list[ if not entity: raise ConfigurationError("Empty host (or extra comma in host list)") port = default_port - # Unix socket entities don't have ports + node = entity + # Decoding happens per entity, after splitting on ",". if entity.endswith(".sock"): + # Unix socket entities don't have ports. Socket paths are the + # only host identifiers permitted to contain reserved + # characters (e.g. "/") that require escaping. + node = unquote_plus(entity) port = None - nodes.append(parse_host(entity, port)) + elif entity.startswith("["): + # An IPv6 zone index is escaped as "%25" (RFC 6874). + node = entity.replace("%25", "%") + elif "%" in entity: + raise InvalidURI( + "Percent-encoding is only allowed in Unix domain socket paths " + f"and IPv6 zone indexes, not in hostnames: {entity}" + ) + nodes.append(parse_host(node, port)) return nodes @@ -576,7 +589,6 @@ def _validate_uri( if "/" in hosts: raise InvalidURI(f"Any '/' in a unix domain socket must be percent-encoded: {host_part}") - hosts = unquote_plus(hosts) fqdn = None srv_max_hosts = srv_max_hosts or options.get("srvMaxHosts") if is_srv: diff --git a/test/test_uri_parser.py b/test/test_uri_parser.py index c16d28b08f..06ab1951c9 100644 --- a/test/test_uri_parser.py +++ b/test/test_uri_parser.py @@ -86,6 +86,17 @@ def test_split_hosts(self): self.assertEqual([("::1", 27017)], split_hosts("[::1]:27017")) self.assertEqual([("::1", 27017)], split_hosts("[::1]")) + def test_split_hosts_percent_encoded_host(self): + # PYTHON-5986: percent-encoding in a hostname is rejected rather than + # decoded. Only socket paths and IPv6 zone indexes are decoded. + self.assertRaises(InvalidURI, split_hosts, "example.com%2Cexample.org%3A27017") + self.assertRaises(InvalidURI, split_hosts, "example.com%2F27017") + + def test_split_hosts_ipv6_zone_index(self): + # An IPv6 zone index is escaped as "%25" (RFC 6874) and must still + # decode, unlike percent-encoding in a plain hostname. + self.assertEqual([("fe80::1%eth0", 27017)], split_hosts("[fe80::1%25eth0]:27017")) + def test_split_options(self): self.assertRaises(ConfigurationError, split_options, "foo") self.assertRaises(ConfigurationError, split_options, "foo=bar;foo") @@ -672,6 +683,10 @@ def test_validate_uri_edge_cases(self): self.assertRaises(InvalidURI, parse_uri, "mongodb://localhost/%24db") self.assertRaises(InvalidURI, parse_uri, "mongodb://localhost/my%20db") + def test_validate_uri_percent_encoded_host(self): + # PYTHON-5986: a percent-encoded hostname is rejected by parse_uri too. + self.assertRaises(InvalidURI, parse_uri, "mongodb://example.com%2Cexample.org%3A27017/") + def test_validate_uri_srv_structure(self): with patch("pymongo.uri_parser_shared._have_dnspython", return_value=True): self.assertRaises( From 713d401952da544178e655f32996894008bf8134 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Thu, 24 Sep 2026 12:19:56 -0400 Subject: [PATCH 2/5] Merge commit from fork * SECBUG-4279 Reject Unix domain socket KMS endpoints _EncryptionIO.kms_request passed the KMS endpoint from a data key's masterKey.endpoint verbatim to parse_host(), which returns strings ending in ".sock" unchanged, bypassing hostname/port validation. _create_connection then treats any address ending in ".sock" as a Unix domain socket path and connects to it with AF_UNIX. Since masterKey.endpoint is read back from the key vault on every KMS request, a party able to write a key vault document could redirect the driver's KMS connection to an arbitrary local socket on the application host. Reject ".sock"-suffixed KMS endpoints immediately after parsing, before any connection is attempted. * PYTHON-5990 Drop spec test number from non-spec test name * PYTHON-5990 Drop duplicated comment from kms_request guard * PYTHON-5990 Reference PYTHON ticket in test and neutralize changelog wording * PYTHON-5990 Remove changelog entry --- pymongo/asynchronous/encryption.py | 2 ++ pymongo/synchronous/encryption.py | 2 ++ test/asynchronous/test_encryption.py | 8 ++++++++ test/test_encryption.py | 8 ++++++++ 4 files changed, 20 insertions(+) diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index 869a0ea5c0..9ba2758f78 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -187,6 +187,8 @@ async def kms_request(self, kms_context: MongoCryptKmsContext) -> None: ssl_context=ctx, ) address = parse_host(endpoint, _HTTPS_PORT) + if address[0].endswith(".sock"): + raise ConfigurationError(f"Invalid KMS endpoint {endpoint!r}") sleep_u = kms_context.usleep if sleep_u: sleep_sec = float(sleep_u) / 1e6 diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index 0d29fd4671..e7d8a366ca 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -186,6 +186,8 @@ def kms_request(self, kms_context: MongoCryptKmsContext) -> None: ssl_context=ctx, ) address = parse_host(endpoint, _HTTPS_PORT) + if address[0].endswith(".sock"): + raise ConfigurationError(f"Invalid KMS endpoint {endpoint!r}") sleep_u = kms_context.usleep if sleep_u: sleep_sec = float(sleep_u) / 1e6 diff --git a/test/asynchronous/test_encryption.py b/test/asynchronous/test_encryption.py index fa41116db2..128f26feb4 100644 --- a/test/asynchronous/test_encryption.py +++ b/test/asynchronous/test_encryption.py @@ -1299,6 +1299,14 @@ async def test_04_kmip_endpoint_invalid_port(self): with self.assertRaisesRegex(EncryptionError, "localhost:12345"): await self.client_encryption.create_data_key("kmip", master_key=master_key) + async def test_kmip_endpoint_unix_socket_rejected(self): + # PYTHON-5990: a masterKey.endpoint ending in ".sock" must not be + # treated as a Unix domain socket path. KMS endpoints must be a TCP + # host[:port]. + master_key = {"keyId": "1", "endpoint": "example.sock"} + with self.assertRaisesRegex(EncryptionError, "Invalid KMS endpoint"): + await self.client_encryption.create_data_key("kmip", master_key=master_key) + @unittest.skipUnless(any(AWS_CREDS.values()), "AWS environment credentials are not set") async def test_05_aws_endpoint_wrong_region(self): master_key = { diff --git a/test/test_encryption.py b/test/test_encryption.py index a358b900e2..adb6005ea1 100644 --- a/test/test_encryption.py +++ b/test/test_encryption.py @@ -1293,6 +1293,14 @@ def test_04_kmip_endpoint_invalid_port(self): with self.assertRaisesRegex(EncryptionError, "localhost:12345"): self.client_encryption.create_data_key("kmip", master_key=master_key) + def test_kmip_endpoint_unix_socket_rejected(self): + # PYTHON-5990: a masterKey.endpoint ending in ".sock" must not be + # treated as a Unix domain socket path. KMS endpoints must be a TCP + # host[:port]. + master_key = {"keyId": "1", "endpoint": "example.sock"} + with self.assertRaisesRegex(EncryptionError, "Invalid KMS endpoint"): + self.client_encryption.create_data_key("kmip", master_key=master_key) + @unittest.skipUnless(any(AWS_CREDS.values()), "AWS environment credentials are not set") def test_05_aws_endpoint_wrong_region(self): master_key = { From f272ebedee9f399a6572409311372ce175ad8921 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 24 Sep 2026 11:34:44 -0500 Subject: [PATCH 3/5] Merge commit from fork * PYTHON-5996 Harden bson buffer size guard against signed integer overflow * PYTHON-5996 Add changelog entry * remove changelog entry for now * Update doc/changelog.rst * Update doc/changelog.rst * Update doc/changelog.rst * Update doc/changelog.rst --- bson/buffer.c | 18 +++++++++++++----- test/test_bson.py | 8 ++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/bson/buffer.c b/bson/buffer.c index cc75202746..68a8fe3590 100644 --- a/bson/buffer.c +++ b/bson/buffer.c @@ -18,6 +18,7 @@ #define PY_SSIZE_T_CLEAN #include "Python.h" +#include #include #include @@ -105,18 +106,25 @@ static int buffer_grow(buffer_t buffer, int min_length) { * Return non-zero and sets MemoryError on allocation failure. * Return non-zero and sets ValueError if `size` would exceed 2GiB. */ static int buffer_assure_space(buffer_t buffer, int size) { - int new_size = buffer->position + size; - /* Check for overflow. */ - if (new_size < buffer->position) { + long long new_size; + if (size < 0) { PyErr_SetString(PyExc_ValueError, "Document would overflow BSON size limit"); return 1; } - if (new_size <= buffer->size) { + /* Compute in a wider type so the addition cannot overflow `int`. */ + new_size = (long long)buffer->position + (long long)size; + if (new_size > INT_MAX) { + PyErr_SetString(PyExc_ValueError, + "Document would overflow BSON size limit"); + return 1; + } + + if ((int)new_size <= buffer->size) { return 0; } - return buffer_grow(buffer, new_size); + return buffer_grow(buffer, (int)new_size); } /* Save `size` bytes from the current position in `buffer` (and grow if needed). diff --git a/test/test_bson.py b/test/test_bson.py index 7ce21dd17f..191a706388 100644 --- a/test/test_bson.py +++ b/test/test_bson.py @@ -691,6 +691,14 @@ def test_overflow(self): self.assertTrue(encode({"x": -9223372036854775808})) self.assertRaises(OverflowError, encode, {"x": -9223372036854775809}) + @unittest.skipUnless(bson.has_c(), "This test requires the C extension") + def test_encode_size_limit(self): + # PYTHON-5996: encoding must raise when a document's encoded size + # exceeds the BSON size limit. + big_value = "a" * (1 << 30) + with self.assertRaises(ValueError): + encode({"a": big_value, "b": big_value, "c": big_value}) + def test_small_long_encode_decode(self): encoded1 = encode({"x": 256}) decoded1 = decode(encoded1)["x"] From 38c8f42a9b64cf39ef8d07f7b643fe608b0e4f33 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Thu, 24 Sep 2026 12:36:44 -0400 Subject: [PATCH 4/5] Prep 4.18.2 release --- doc/changelog.rst | 18 ++++++++++++++++++ pymongo/_version.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 921c19648a..37355eb19a 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,24 @@ Changelog ========= +Changes in Version 4.18.2 (2026/09/24) +-------------------------------------- + +Version 4.18.2 is a bug fix release. + +- Hardened the bson buffer size guard against signed integer overflow. +- Fixed connection string parsing to percent-decode each host individually. +- Client-side field level encryption now rejects a KMS endpoint ending in + ``.sock``. + +Issues Resolved +............... + +See the `PyMongo 4.18.2 release notes in JIRA`_ for the list of resolved issues +in this release. + +.. _PyMongo 4.18.2 release notes in JIRA: https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=10004&version=52896 + Changes in Version 4.18.1 (2026/09/10) -------------------------------------- diff --git a/pymongo/_version.py b/pymongo/_version.py index 14c8773d10..d95ee38f58 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -19,7 +19,7 @@ import re from typing import Union -__version__ = "4.18.2.dev0" +__version__ = "4.18.2" def get_version_tuple(version: str) -> tuple[Union[int, str], ...]: From 6367801eaf7684122c5a4915bb1ab19ae98a98f5 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Thu, 24 Sep 2026 12:39:19 -0400 Subject: [PATCH 5/5] Fix static failure --- doc/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 37355eb19a..ad82e97516 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -27,7 +27,7 @@ Version 4.18.1 is a bug fix release. - Use an exact match for the file ID in GridFS delete methods (`CVE-2026-88029`_). -.. CVE-2026-88029: https://www.cve.org/CVERecord?id=CVE-2026-88029 +.. _CVE-2026-88029: https://www.cve.org/CVERecord?id=CVE-2026-88029 Changes in Version 4.18.0 (2026/09/03) --------------------------------------