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/doc/changelog.rst b/doc/changelog.rst index 921c19648a..ad82e97516 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) -------------------------------------- @@ -9,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) -------------------------------------- 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], ...]: 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/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/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/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/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_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"] 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 = { 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(