diff --git a/HISTORY.md b/HISTORY.md index 4fda387fbe..aec56d70ae 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,27 @@ dev - \[Short description of non-trivial change.\] +2.27.1.post4 (2026-11-05) +------------------------- + +**Security hardening** + +- `no_proxy` entries now match whole domain labels only: + `no_proxy=example.com` still bypasses the proxy for `example.com` and + `sub.example.com`, but no longer for `badexample.com`. A leading dot on an + entry is ignored. Backport of requests #7427 (2.34.0), the port of CPython + bpo-39057. No CVE or GHSA. Python 2 compatible. + +**Bugfix** + +- `check_compatibility()` accepts urllib3 post-release versions such as the + ActiveState `1.26.20.post4` build. It used to raise ValueError on them, + which `import requests` turned into a `RequestsDependencyWarning`. + +This release uses the `X.Y.Z.postN` version scheme, replacing the +`+security.N` local versions of earlier ActiveState releases (inventory +2.27.1.4). + 2.27.1+security.3 (2026-06-12) ------------------------------- diff --git a/requests/__init__.py b/requests/__init__.py index ab56c206d5..e1ebdf83e9 100644 --- a/requests/__init__.py +++ b/requests/__init__.py @@ -62,6 +62,11 @@ def check_compatibility(urllib3_version, chardet_version, charset_normalizer_ver urllib3_version = urllib3_version.split('+', 1)[0].split('.') assert urllib3_version != ['dev'] # Verify urllib3 isn't installed from git. + # Only major.minor.patch matter. Drop a PEP 440 post-release segment such + # as the ActiveState '1.26.20.post4' builds, which would otherwise break + # the three-way unpack below. + urllib3_version = urllib3_version[:3] + # Sometimes, urllib3 only reports its version as 16.1. if len(urllib3_version) == 2: urllib3_version.append('0') diff --git a/requests/__version__.py b/requests/__version__.py index 0c956c86f7..eff6ce07b1 100644 --- a/requests/__version__.py +++ b/requests/__version__.py @@ -5,7 +5,7 @@ __title__ = 'requests' __description__ = 'Python HTTP for Humans.' __url__ = 'https://requests.readthedocs.io' -__version__ = '2.27.1+security.3' +__version__ = '2.27.1.post4' __build__ = 0x022701 __author__ = 'Kenneth Reitz' __author_email__ = 'me@kennethreitz.org' diff --git a/requests/utils.py b/requests/utils.py index 0ac98e6ace..ed5f062e1c 100644 --- a/requests/utils.py +++ b/requests/utils.py @@ -778,6 +778,13 @@ def should_bypass_proxies(url, no_proxy): host_with_port += ':{}'.format(parsed.port) for host in no_proxy: + # Match whole domain labels only, so that "example.com" does + # not also bypass the proxy for "badexample.com". A leading + # dot on the entry is ignored. (CPython bpo-39057) + host = host.lstrip('.') + if parsed.hostname == host or host_with_port == host: + return True + host = '.' + host if parsed.hostname.endswith(host) or host_with_port.endswith(host): # The URL does match something in no_proxy, so we don't want # to apply the proxies on this URL. diff --git a/tests/test_check_compatibility.py b/tests/test_check_compatibility.py new file mode 100644 index 0000000000..c93df6a9bc --- /dev/null +++ b/tests/test_check_compatibility.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""check_compatibility must accept the version strings that ActiveState's +urllib3 builds report: plain, PEP 440 local (+security.N) and post-release +(.postN).""" + +import pytest + +from requests import check_compatibility + + +@pytest.mark.parametrize('urllib3_version', ( + '1.26.20', + '1.26', + '1.26.20+security.2', + '1.26.20.post3', + '1.26.20.post4', + '1.21.1', +)) +def test_supported_urllib3_versions(urllib3_version): + check_compatibility(urllib3_version, '3.0.4', None) + + +@pytest.mark.parametrize('urllib3_version', ('2.0.7', '1.20.0', '1.27.0.post1')) +def test_unsupported_urllib3_versions(urllib3_version): + with pytest.raises(AssertionError): + check_compatibility(urllib3_version, '3.0.4', None) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8e98397204..26b959a5a0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -728,6 +728,28 @@ def test_should_bypass_proxies_no_proxy( assert should_bypass_proxies(url, no_proxy=no_proxy) == expected +@pytest.mark.parametrize( + 'url, expected', ( + ('http://localhost/', True), + ('http://anotherdomain.com:8888/', True), + ('http://newdomain.com:1234/', True), + ('http://www.newdomain.com:1234/', True), + ('http://foo.d.o.t/', True), + ('http://d.o.t/', True), + ('http://prelocalhost/', False), + ('http://badanotherdomain.com/', False), + ('http://newdomain.com/', False), + ('http://newdomain.com:1235/', False), + )) +def test_should_bypass_proxies_no_proxy_domain_boundary(url, expected): + """Ensure no_proxy matching respects domain boundaries and does not + greedily match domains that merely end with the no_proxy entry. + See CPython bpo-39057 and requests #7427. + """ + no_proxy = 'localhost, anotherdomain.com, newdomain.com:1234, .d.o.t' + assert should_bypass_proxies(url, no_proxy=no_proxy) == expected + + @pytest.mark.skipif(os.name != 'nt', reason='Test only on Windows') @pytest.mark.parametrize( 'url, expected, override', (