From b3536c869675b49a1a6c4d2086da939c51563c83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:30:30 +0000 Subject: [PATCH 1/6] Bump filelock from 3.29.5 to 3.29.6 (#13073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.29.5 to 3.29.6.
Release notes

Sourced from filelock's releases.

3.29.6

What's Changed

Full Changelog: https://github.com/tox-dev/filelock/compare/3.29.5...3.29.6

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=filelock&package-manager=pip&previous-version=3.29.5&new-version=3.29.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/constraints.txt | 2 +- requirements/dev.txt | 2 +- requirements/lint.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index ad64e6e7207..736fb896ccd 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -80,7 +80,7 @@ exceptiongroup==1.3.1 # via pytest execnet==2.1.2 # via pytest-xdist -filelock==3.29.5 +filelock==3.29.6 # via # python-discovery # virtualenv diff --git a/requirements/dev.txt b/requirements/dev.txt index 3da010bd9c3..f9931bb44ef 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -78,7 +78,7 @@ exceptiongroup==1.3.1 # via pytest execnet==2.1.2 # via pytest-xdist -filelock==3.29.5 +filelock==3.29.6 # via # python-discovery # virtualenv diff --git a/requirements/lint.txt b/requirements/lint.txt index 51b7f4df981..29a837b8fd2 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -38,7 +38,7 @@ distlib==0.4.3 # via virtualenv exceptiongroup==1.3.1 # via pytest -filelock==3.29.5 +filelock==3.29.6 # via # python-discovery # virtualenv From 69767a0d8cfd7c49f5a59cdb6c5c2a2fe175e7f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:44:23 +0000 Subject: [PATCH 2/6] Bump charset-normalizer from 3.4.7 to 3.4.8 (#13075) Bumps [charset-normalizer](https://github.com/jawah/charset_normalizer) from 3.4.7 to 3.4.8.
Release notes

Sourced from charset-normalizer's releases.

Version 3.4.8

3.4.8 (2026-07-06)

Fixed

  • Wall import time due to cascade codec imports for our multibyte first sort of iana supported codecs (#742)
  • Unnecessary json import at runtime (#753)
  • Inverse capitalization not seen by noise detector (#731)

Changed

  • No longer holding a global cache for our noise / coherence measurements. Relax RSS memory usage.
  • Micro-optimizations in our noise / coherence measurements.
  • No longer using regex search by default for our preemptive charset mark algorithm.
  • Raised upperbound of setuptools to v83.
  • Raised upperbound of mypy(c) to v2.1.

Removed

  • Redundant UTF7 BOM marker (#730)
Changelog

Sourced from charset-normalizer's changelog.

3.4.8 (2026-07-06)

Fixed

  • Wall import time due to cascade codec imports for our multibyte first sort of iana supported codecs (#742)
  • Unnecessary json import at runtime (#753)
  • Inverse capitalization not seen by noise detector (#731)

Changed

  • No longer holding a global cache for our noise / coherence measurements. Relax RSS memory usage.
  • Micro-optimizations in our noise / coherence measurements.
  • No longer using regex search by default for our preemptive charset mark algorithm.
  • Raised upperbound of setuptools to v83.
  • Raised upperbound of mypy(c) to v2.1.

Removed

  • Redundant UTF7 BOM marker (#730)
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=charset-normalizer&package-manager=pip&previous-version=3.4.7&new-version=3.4.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/constraints.txt | 2 +- requirements/dev.txt | 2 +- requirements/doc-spelling.txt | 2 +- requirements/doc.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 736fb896ccd..a9e431600e2 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -55,7 +55,7 @@ cffi==2.0.0 # pycares cfgv==3.5.0 # via pre-commit -charset-normalizer==3.4.7 +charset-normalizer==3.4.8 # via requests click==8.4.2 # via diff --git a/requirements/dev.txt b/requirements/dev.txt index f9931bb44ef..79e1e8eca49 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -55,7 +55,7 @@ cffi==2.0.0 # pycares cfgv==3.5.0 # via pre-commit -charset-normalizer==3.4.7 +charset-normalizer==3.4.8 # via requests click==8.4.2 # via diff --git a/requirements/doc-spelling.txt b/requirements/doc-spelling.txt index 4c35bc3fc56..d54a504c8b2 100644 --- a/requirements/doc-spelling.txt +++ b/requirements/doc-spelling.txt @@ -12,7 +12,7 @@ babel==2.18.0 # via sphinx certifi==2026.6.17 # via requests -charset-normalizer==3.4.7 +charset-normalizer==3.4.8 # via requests click==8.4.2 # via towncrier diff --git a/requirements/doc.txt b/requirements/doc.txt index 9beee0e7a25..209f526bb24 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -12,7 +12,7 @@ babel==2.18.0 # via sphinx certifi==2026.6.17 # via requests -charset-normalizer==3.4.7 +charset-normalizer==3.4.8 # via requests click==8.4.2 # via towncrier From 08979d15383bd97f0fb7e7c90ab847e8950e6619 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:57:43 +0000 Subject: [PATCH 3/6] Bump virtualenv from 21.5.1 to 21.6.0 (#13076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [virtualenv](https://github.com/pypa/virtualenv) from 21.5.1 to 21.6.0.
Release notes

Sourced from virtualenv's releases.

21.6.0

What's Changed

Full Changelog: https://github.com/pypa/virtualenv/compare/21.5.2...21.6.0

21.5.2

What's Changed

Full Changelog: https://github.com/pypa/virtualenv/compare/21.5.1...21.5.2

Changelog

Sourced from virtualenv's changelog.

Features - 21.6.0

  • Stop installing the _virtualenv.{py,pth} distutils import hook for Python 3.10 and later, where pip, setuptools and CPython already ignore the install config keys it guards against; this removes the hook's startup import cost. The hook is still installed for Python 3.9 - :issue:3181. (:issue:3181)

v21.5.2 (2026-07-06)


Bugfixes - 21.5.2

  • Upgrade embedded wheels:

    • setuptools to 83.0.0 (:issue:3180)

v21.5.1 (2026-06-16)


Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/constraints.txt | 2 +- requirements/dev.txt | 2 +- requirements/lint.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index a9e431600e2..2b0592c8c12 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -328,7 +328,7 @@ uvloop==0.22.1 ; platform_system != "Windows" # -r requirements/lint.in valkey==6.1.1 # via -r requirements/lint.in -virtualenv==21.5.1 +virtualenv==21.6.0 # via pre-commit wait-for-it==2.3.0 # via -r requirements/test-common-base.in diff --git a/requirements/dev.txt b/requirements/dev.txt index 79e1e8eca49..6527c40ac3f 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -318,7 +318,7 @@ uvloop==0.22.1 ; platform_system != "Windows" and implementation_name == "cpytho # -r requirements/lint.in valkey==6.1.1 # via -r requirements/lint.in -virtualenv==21.5.1 +virtualenv==21.6.0 # via pre-commit wait-for-it==2.3.0 # via -r requirements/test-common-base.in diff --git a/requirements/lint.txt b/requirements/lint.txt index 29a837b8fd2..3d5fcbd3f83 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -162,7 +162,7 @@ uvloop==0.22.1 ; platform_system != "Windows" # via -r requirements/lint.in valkey==6.1.1 # via -r requirements/lint.in -virtualenv==21.5.1 +virtualenv==21.6.0 # via pre-commit yarl==1.24.2 # via aiohttp From 649887ca860437e610fc4af5c8363a9be6f19681 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:03:48 +0000 Subject: [PATCH 4/6] Bump cffi from 2.0.0 to 2.1.0 (#13079) Bumps [cffi](https://github.com/python-cffi/cffi) from 2.0.0 to 2.1.0.
Release notes

Sourced from cffi's releases.

v2.1.0

  • Added support for Python 3.15 and support for C extensions generated by CFFI to target the new abi3t free-threaded ABI.
  • Dropped support for Python 3.9.
  • Added cffi-gen-src CLI to generate CFFI C extension source for alternate build backend support.
  • Fixed crashes inside __delitem__.
  • Fixed "string too big" error under MSVC.
  • Fixed mingw builds.
  • Added support for arm64 iOS wheels.
Commits
  • d9f6f70 New release 2.1.0
  • 02a7b0e Misc pre-2.1 release/packaging cleanup (#253)
  • 1362e5d Move cffi-gen-src release note to 2.1.0 notes
  • a797055 Make error message when embedding version test fails more friendly
  • f1f40a8 Update changelog
  • dc62c93 Delete missed cp39 Windows builds
  • a341180 Update version numbers to prepare for v2.1 release
  • 9f04d85 Mark test using inet_ntoa as thread-unsafe
  • 5f12702 Fix flaky pytest-run-parallel CI crash
  • 26b3d3a Merge branch 'main' into integrate-buildtool
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/base-ft.txt | 2 +- requirements/base.txt | 2 +- requirements/constraints.txt | 2 +- requirements/dev.txt | 2 +- requirements/lint.txt | 2 +- requirements/runtime-deps.txt | 2 +- requirements/test-common.txt | 2 +- requirements/test-ft.txt | 2 +- requirements/test-mobile.txt | 2 +- requirements/test.txt | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/requirements/base-ft.txt b/requirements/base-ft.txt index 13aa99dea88..8a19c868ca9 100644 --- a/requirements/base-ft.txt +++ b/requirements/base-ft.txt @@ -16,7 +16,7 @@ backports-zstd==1.3.0 ; platform_python_implementation == "CPython" and python_v # via -r requirements/runtime-deps.in brotli==1.2.0 ; platform_python_implementation == "CPython" and sys_platform != "android" and sys_platform != "ios" # via -r requirements/runtime-deps.in -cffi==2.0.0 +cffi==2.1.0 # via pycares frozenlist==1.8.0 # via diff --git a/requirements/base.txt b/requirements/base.txt index 942f53280dd..f575b84579d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -16,7 +16,7 @@ backports-zstd==1.3.0 ; platform_python_implementation == "CPython" and python_v # via -r requirements/runtime-deps.in brotli==1.2.0 ; platform_python_implementation == "CPython" and sys_platform != "android" and sys_platform != "ios" # via -r requirements/runtime-deps.in -cffi==2.0.0 +cffi==2.1.0 # via pycares frozenlist==1.8.0 # via diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 2b0592c8c12..a9eef63a071 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -49,7 +49,7 @@ build==1.5.0 # via pip-tools certifi==2026.6.17 # via requests -cffi==2.0.0 +cffi==2.1.0 # via # cryptography # pycares diff --git a/requirements/dev.txt b/requirements/dev.txt index 6527c40ac3f..3f1fc5a4787 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -49,7 +49,7 @@ build==1.5.0 # via pip-tools certifi==2026.6.17 # via requests -cffi==2.0.0 +cffi==2.1.0 # via # cryptography # pycares diff --git a/requirements/lint.txt b/requirements/lint.txt index 3d5fcbd3f83..9afaf2cbc8a 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -26,7 +26,7 @@ backports-zstd==1.3.0 ; implementation_name == "cpython" and python_version < "3 # via -r requirements/lint.in blockbuster==1.5.26 # via -r requirements/lint.in -cffi==2.0.0 +cffi==2.1.0 # via # cryptography # pycares diff --git a/requirements/runtime-deps.txt b/requirements/runtime-deps.txt index d9c83ad7434..30739934284 100644 --- a/requirements/runtime-deps.txt +++ b/requirements/runtime-deps.txt @@ -16,7 +16,7 @@ backports-zstd==1.3.0 ; platform_python_implementation == "CPython" and python_v # via -r requirements/runtime-deps.in brotli==1.2.0 ; platform_python_implementation == "CPython" and sys_platform != "android" and sys_platform != "ios" # via -r requirements/runtime-deps.in -cffi==2.0.0 +cffi==2.1.0 # via pycares frozenlist==1.8.0 # via diff --git a/requirements/test-common.txt b/requirements/test-common.txt index db338f537ce..9bf331379d8 100644 --- a/requirements/test-common.txt +++ b/requirements/test-common.txt @@ -20,7 +20,7 @@ backports-asyncio-runner==1.2.0 # via pytest-asyncio blockbuster==1.5.26 # via -r requirements/test-common.in -cffi==2.0.0 +cffi==2.1.0 # via cryptography click==8.4.2 # via wait-for-it diff --git a/requirements/test-ft.txt b/requirements/test-ft.txt index 41e25c61276..280b9266273 100644 --- a/requirements/test-ft.txt +++ b/requirements/test-ft.txt @@ -32,7 +32,7 @@ blockbuster==1.5.26 # via -r requirements/test-common.in brotli==1.2.0 ; platform_python_implementation == "CPython" and sys_platform != "android" and sys_platform != "ios" # via -r requirements/runtime-deps.in -cffi==2.0.0 +cffi==2.1.0 # via # cryptography # pycares diff --git a/requirements/test-mobile.txt b/requirements/test-mobile.txt index 7a585e04fdf..9099cd24535 100644 --- a/requirements/test-mobile.txt +++ b/requirements/test-mobile.txt @@ -28,7 +28,7 @@ backports-zstd==1.3.0 ; platform_python_implementation == "CPython" and python_v # via -r requirements/runtime-deps.in brotli==1.2.0 ; platform_python_implementation == "CPython" and sys_platform != "android" and sys_platform != "ios" # via -r requirements/runtime-deps.in -cffi==2.0.0 ; sys_platform != "android" and sys_platform != "ios" +cffi==2.1.0 ; sys_platform != "android" and sys_platform != "ios" # via # -r requirements/test-mobile.in # pycares diff --git a/requirements/test.txt b/requirements/test.txt index 1bd63d10602..30722b73010 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -32,7 +32,7 @@ blockbuster==1.5.26 # via -r requirements/test-common.in brotli==1.2.0 ; platform_python_implementation == "CPython" and sys_platform != "android" and sys_platform != "ios" # via -r requirements/runtime-deps.in -cffi==2.0.0 +cffi==2.1.0 # via # cryptography # pycares From 8040e0ea638ec345b21e6f553b24000e5af64fd0 Mon Sep 17 00:00:00 2001 From: oppnc <129483880+oppnc@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:34:22 +0800 Subject: [PATCH 5/6] Fix Windows localhost resolution without active network (#12700) --- CHANGES/5357.bugfix.rst | 3 ++ CONTRIBUTORS.txt | 1 + aiohttp/resolver.py | 56 ++++++++++++++++------ tests/test_resolver.py | 102 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 CHANGES/5357.bugfix.rst diff --git a/CHANGES/5357.bugfix.rst b/CHANGES/5357.bugfix.rst new file mode 100644 index 00000000000..dd0125cb9ba --- /dev/null +++ b/CHANGES/5357.bugfix.rst @@ -0,0 +1,3 @@ +Fixed resolving ``localhost`` on Windows to fall back without ``AI_ADDRCONFIG`` +when the first lookup fails, so ``localhost`` still works without an active +network. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 65c20d023e7..88875d58397 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -297,6 +297,7 @@ Oisin Aylward Olaf Conradi Oleg Höfling Omkar Kabde +oppnc Pahaz Blinov Panagiotis Kolokotronis Pankaj Pandey diff --git a/aiohttp/resolver.py b/aiohttp/resolver.py index d29ffff1d95..26a04a4dffb 100644 --- a/aiohttp/resolver.py +++ b/aiohttp/resolver.py @@ -1,5 +1,6 @@ import asyncio import socket +import sys import weakref from typing import Any, Optional @@ -22,6 +23,11 @@ _AI_ADDRCONFIG = socket.AI_ADDRCONFIG if hasattr(socket, "AI_MASK"): _AI_ADDRCONFIG &= socket.AI_MASK +_IS_WINDOWS = sys.platform == "win32" + + +def _is_windows_localhost(host: str) -> bool: + return _IS_WINDOWS and host.rstrip(".").casefold() == "localhost" class ThreadedResolver(AbstractResolver): @@ -37,13 +43,24 @@ def __init__(self) -> None: async def resolve( self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET ) -> list[ResolveResult]: - infos = await self._loop.getaddrinfo( - host, - port, - type=socket.SOCK_STREAM, - family=family, - flags=_AI_ADDRCONFIG, - ) + try: + infos = await self._loop.getaddrinfo( + host, + port, + type=socket.SOCK_STREAM, + family=family, + flags=_AI_ADDRCONFIG, + ) + except socket.gaierror: + if not _is_windows_localhost(host): + raise + infos = await self._loop.getaddrinfo( + host, + port, + type=socket.SOCK_STREAM, + family=family, + flags=0, + ) hosts: list[ResolveResult] = [] for family, _, proto, _, address in infos: @@ -105,13 +122,24 @@ async def resolve( self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET ) -> list[ResolveResult]: try: - resp = await self._resolver.getaddrinfo( - host, - port=port, - type=socket.SOCK_STREAM, - family=family, - flags=_AI_ADDRCONFIG, - ) + try: + resp = await self._resolver.getaddrinfo( + host, + port=port, + type=socket.SOCK_STREAM, + family=family, + flags=_AI_ADDRCONFIG, + ) + except aiodns.error.DNSError: + if not _is_windows_localhost(host): + raise + resp = await self._resolver.getaddrinfo( + host, + port=port, + type=socket.SOCK_STREAM, + family=family, + flags=0, + ) except aiodns.error.DNSError as exc: msg = exc.args[1] if len(exc.args) >= 1 else "DNS lookup failed" raise OSError(None, msg) from exc diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 778f6d15131..4948539aed1 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable, Collection, Generator from ipaddress import ip_address from typing import Any, NamedTuple -from unittest.mock import Mock, create_autospec, patch +from unittest.mock import AsyncMock, Mock, call, create_autospec, patch import pytest @@ -232,6 +232,44 @@ async def test_async_resolver_negative_lookup() -> None: await resolver.close() +@pytest.mark.skipif(not getaddrinfo, reason="aiodns >=3.2.0 required") +@pytest.mark.usefixtures("check_no_lingering_resolvers") +async def test_async_resolver_retries_localhost_without_addrconfig_on_windows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("aiohttp.resolver._IS_WINDOWS", True) + with patch("aiodns.DNSResolver") as mock: + mock().getaddrinfo.side_effect = [ + aiodns.error.DNSError(1, "addrconfig failed"), + fake_aiodns_getaddrinfo_ipv4_result(["127.0.0.1"]), + ] + resolver = AsyncResolver() + try: + real = await resolver.resolve("localhost", family=socket.AF_UNSPEC) + finally: + await resolver.close() + + assert real[0]["host"] == "127.0.0.1" + mock().getaddrinfo.assert_has_calls( + [ + call( + "localhost", + family=socket.AF_UNSPEC, + flags=socket.AI_ADDRCONFIG, + port=0, + type=socket.SOCK_STREAM, + ), + call( + "localhost", + family=socket.AF_UNSPEC, + flags=0, + port=0, + type=socket.SOCK_STREAM, + ), + ] + ) + + @pytest.mark.skipif(not getaddrinfo, reason="aiodns >=3.2.0 required") @pytest.mark.usefixtures("check_no_lingering_resolvers") async def test_async_resolver_no_hosts_in_getaddrinfo() -> None: @@ -253,6 +291,68 @@ async def test_threaded_resolver_positive_lookup() -> None: ipaddress.ip_address(real[0]["host"]) +async def test_threaded_resolver_retries_localhost_without_addrconfig_on_windows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("aiohttp.resolver._IS_WINDOWS", True) + loop = Mock() + loop.getaddrinfo = AsyncMock( + side_effect=[ + socket.gaierror(), + [(socket.AF_INET, None, socket.SOCK_STREAM, None, ("127.0.0.1", 0))], + ] + ) + resolver = ThreadedResolver() + resolver._loop = loop + + real = await resolver.resolve("localhost", family=socket.AF_UNSPEC) + + assert real[0]["host"] == "127.0.0.1" + loop.getaddrinfo.assert_has_calls( + [ + call( + "localhost", + 0, + type=socket.SOCK_STREAM, + family=socket.AF_UNSPEC, + flags=socket.AI_ADDRCONFIG, + ), + call( + "localhost", + 0, + type=socket.SOCK_STREAM, + family=socket.AF_UNSPEC, + flags=0, + ), + ] + ) + + +@pytest.mark.parametrize( + ("host", "is_windows"), + (("localhost", False), ("www.python.org", True)), +) +async def test_threaded_resolver_keeps_addrconfig_without_localhost_windows_fallback( + host: str, is_windows: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("aiohttp.resolver._IS_WINDOWS", is_windows) + loop = Mock() + loop.getaddrinfo = AsyncMock(side_effect=socket.gaierror()) + resolver = ThreadedResolver() + resolver._loop = loop + + with pytest.raises(socket.gaierror): + await resolver.resolve(host, family=socket.AF_UNSPEC) + + loop.getaddrinfo.assert_called_once_with( + host, + 0, + type=socket.SOCK_STREAM, + family=socket.AF_UNSPEC, + flags=socket.AI_ADDRCONFIG, + ) + + async def test_threaded_resolver_positive_ipv6_link_local_lookup() -> None: loop = Mock() loop.getaddrinfo = fake_ipv6_addrinfo(["fe80::1"]) From ce99eac8db1073e4bbe4c8b3d0913900e0b3a758 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 7 Jul 2026 17:28:12 +0100 Subject: [PATCH 6/6] Threat model chapter 4 (#12706) --- THREAT_MODEL.md | 152 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 143 insertions(+), 9 deletions(-) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 4b5af95cbd4..f7b0aed090a 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -313,6 +313,17 @@ into `StreamReader`) is then handed to `web_protocol.RequestHandler` and Werkzeug emit duplicate `Content-Type` / `Server`); fix disables the check on the response parser (lax mode) while keeping it on the request parser (strict). +- **GHSA-63hw-fmq6-xxg2 (CVE-2026-54277)** (3.14.1) — the + Cython / llhttp request parser failed to enforce `max_line_size` + when a single request or header line arrived across multiple TCP + segments, letting an attacker stream an oversized line in small + fragments to exhaust memory. +- **GHSA-4fvr-rgm6-gqmc (CVE-2026-54273)** (3.14.1) — no cap + on the number of HTTP/1 pipelined requests queued on a single + connection; the `_messages` deque in `web_protocol.RequestHandler` + could grow without bound. Memory was previously bounded only + transitively by `read_bufsize` ([§5.7](#57-server-connection-lifecycle) threat 7.4); + the fix adds an explicit pipeline-count cap. These are all currently in place; this section assumes no regression. @@ -546,20 +557,143 @@ client-side, the writer adds masks to outgoing frames. `max_msg_size + 1` and rejects with `MESSAGE_TOO_BIG` (1009) on overflow. This is the primary mitigation for zip-bomb-style attacks against WebSocket peers. +- **GHSA-xcgm-r5h9-7989 (CVE-2026-54274)** (3.14.1) — a peer could + send large WebSocket frames with incomplete payloads, bypassing the + per-frame memory limit because the buffer grew before the + `max_msg_size` check fired against the (still-unknown) final size. + Fixed by accounting for the in-flight buffer in the cap. - **PR #12976** — the client created its `WebSocketReader` without passing `compress`, so the reader defaulted to `compress=True` and decompressed RSV1 frames even when PMCE was never negotiated (threat 3.3; RFC 6455 §5.2 requires failing such frames). Fixed by passing `compress=bool(compress)` in `client.py:_ws_connect` and removing the `compress` / `decode_text` defaults on `WebSocketReader.__init__`. -- No formal CVE has been published against the WebSocket framing layer to - date. -**Open questions.** +--- + +### 5.4. Multipart parsing & encoding + +**Scope.** Parsing of incoming `multipart/*` bodies (`MultipartReader`, +`BodyPartReader`) and construction of outgoing multipart bodies +(`MultipartWriter`, `FormData`). This includes boundary handling, per-part +header parsing, `Content-Disposition` extraction, `Content-Transfer-Encoding` +decoding, charset detection, and the `Request.post()` path that wraps +`multipart/form-data`. Out of scope: the underlying compression codecs ([§5.5](#55-compression-codecs)), +streams/payloads ([§5.6](#56-streams--payloads)), and the connection-level read/write timeouts that +back-stop slow drips ([§5.7](#57-server-connection-lifecycle)). + +**Components covered.** + +- `aiohttp/multipart.py` — `MultipartReader`, `BodyPartReader`, + `MultipartWriter`, `parse_content_disposition`, + `content_disposition_filename`. +- `aiohttp/formdata.py` — `FormData` builder. +- `aiohttp/web_request.py` — `Request.post()`, `Request.multipart()`. +- `aiohttp/payload.py` — multipart payload wrapping (overlap with [§5.6](#56-streams--payloads)). + +**Trust boundaries & data flow.** + +```mermaid +flowchart LR + Wire([Untrusted body]) --> Reader[MultipartReader] + Reader --> Parts[BodyPartReader per part] + Parts -->|headers, name, filename| App([User handler]) + Parts -->|streamed body| App + Reader -. nested .-> Reader + + AppOut([User code]) --> FD[FormData] --> Writer[MultipartWriter] + Writer --> WireOut([Outbound body]) +``` + +The reader's input is fully attacker-controlled on the server side and +upstream-controlled on the client side. Per-part values surfaced to user +code (`headers`, `name`, `filename`, body bytes) are all untrusted. The +writer's input is trusted in the threat-model sense, but `FormData` is the +boundary at which user-supplied strings can become wire bytes. + +**Assets at risk (chunk-specific).** -1. Should server-side reader reject unmasked frames (and client-side reject - masked ones) per RFC 6455 §5.1? (Threat 3.1 — recommended.) -2. Is the PRNG mask source (`random.getrandbits`) sufficient, or should it be - migrated to `secrets`/`os.urandom`? (Threat 3.2.) -3. For long-lived WebSocket sessions, is there a use case for *forcing* - `no_context_takeover` defaults to limit memory growth? (Threat 3.10.) +- **Process memory** — number of parts, per-part body size, recursion depth + for nested multipart. +- **Filesystem safety in user code** — `filename` round-trips from wire to + application without sanitisation. +- **Outbound framing integrity** — boundary uniqueness, header validity in + `FormData.add_field`. + +**Threats (STRIDE).** + +| # | Component / Vector | STRIDE | Threat | Risk | +| :--- | :--- | :--- | :--- | :--- | +| 4.1 | Boundary parameter parsing | T | Malformed boundary parameter (oversized, missing, or containing bytes outside the RFC 2046 §5.1.1 safe set — digits, letters, and a small punctuation set) could enable multipart parser confusion or smuggling. | Low | +| 4.2 | Number of parts per body | D | A peer submits a body packed with many tiny parts (e.g. ten thousand 100-byte parts inside a 1 MiB body). Each part allocates a `BodyPartReader` plus header dict, so the live-Python-object footprint is far larger than the on-wire byte count. `client_max_size` caps the wire bytes but not the per-part allocation amplification. | Low | +| 4.3 | Nested multipart recursion | D | `MultipartReader.next()` recurses into nested multiparts without a depth cap; deeply nested input can hit `RecursionError`. `Request.post()` short-circuits this by rejecting any nested multipart it sees, but the bare API does not. | Medium | +| 4.4 | Per-part header block size | D | A peer submits a part with an oversized header block (very long field values, or hundreds of headers per part) to drive memory growth at parse time, multiplied across many parts. | Low | +| 4.5 | Per-part body size | D | A peer submits a single part with a body that grows arbitrarily large before any framing boundary — if size checking happens only after buffering the whole part, memory blows up before the cap fires. | Low | +| 4.6 | `Content-Disposition` filename | T / I | Filename is parsed (incl. RFC 2231 `filename*=`) and returned verbatim to the application. Path-traversal strings (`../`), absolute paths, NUL/CR/LF all reach user code unsanitised. | Medium | +| 4.7 | `Content-Disposition` name | T | Field name returned verbatim. CR/LF in a part's `name` doesn't cross the framing boundary (the multipart parser delimits parts by boundary, not by CR/LF in field names), but downstream loggers / dashboards may misrender. | Low | +| 4.8 | `_charset_` magic form field | T | An attacker can include a form field named `_charset_` whose value retroactively becomes the *default charset* used to decode subsequent text fields (`multipart.py:MultipartReader.next _charset_ form handling`). Surprising behaviour; documented in the standard. | Low | +| 4.9 | `Content-Transfer-Encoding` (base64 / quoted-printable) | T / D | A peer sets `Content-Transfer-Encoding: base64` (or `quoted-printable`) and submits malformed encoded data — the decoder raises mid-stream and the partially-decoded part is surfaced to the handler with an exception. | Low | +| 4.10 | `Content-Encoding` (gzip/deflate within a part) | D | A peer submits a multipart part with `Content-Encoding: gzip` (or `deflate`) carrying a zip-bomb payload — a small compressed body that expands to a vastly larger decoded body, driving memory exhaustion in the receiving handler. | Low | +| 4.11 | `MultipartWriter` boundary collision | T | If a part body contains a byte sequence matching the boundary string, the writer emits it as-is — a receiver parsing the multipart will see a fake part break early. Negligible against the default `uuid.uuid4().hex` boundary, but real if user code supplies a short or predictable one. | Low | +| 4.12 | `FormData.add_field` argument injection | T | When user code opts into `FormData(quote_fields=False)`, attacker-controlled `name` or `filename` reach the outbound `Content-Disposition` value with only `\` / `"` escaped. CR/LF/NUL would let an attacker inject extra disposition parameters or a fake-part break-out; `add_field` now rejects these bytes in `name`, `filename`, and `content_type` so the opt-out path is also defended. | Low–Med | +| 4.13 | Per-part header injection via `headers=` | T | Caller-supplied headers passed via `MultipartWriter.append(headers=...)` or `Payload(headers=...)` reach the wire through `Payload._binary_headers`, which serialises `f"{k}: {v}\r\n"` without CR/LF/NUL validation. An attacker-influenced header value can inject extra part headers or break out into a fake part. Distinct from 4.12, which only covers the `name` / `filename` parameters of `add_field`. | Medium | +| 4.14 | `Request.post()` temp-file lifetime | I / D | Uploaded files are streamed to `tempfile.TemporaryFile()` and lifetime is bound to the `FileField` object. If user code drops the field reference without reading or closing, garbage collection eventually closes the temp file. | Low | + +**Mitigations.** + +| # | Threat | Existing | Recommended | +| :--- | :--- | :--- | :--- | +| 4.1 | Boundary parameter | 70-char cap; missing-boundary raises; HTTP header layer ([§5.1](#51-http1-parser)) catches CR/LF/NUL. | None. | +| 4.2 | Many small parts | `client_max_size` caps total bytes. | Documented design decision: rely on `client_max_size` rather than introducing a `max_parts` knob. **User**: operators sensitive to live-object count should reduce `client_max_size`. | +| 4.3 | Nested-multipart recursion | `Request.post()` rejects any nested multipart with `ValueError` ("To decode nested multipart you need to use custom reader") (`web_request.py:BaseRequest.post`). | **Direct `MultipartReader` users get unlimited recursion. Add a `max_nesting_depth` parameter (default e.g. 10) to fail cleanly before `RecursionError`.** | +| 4.4 | Per-part headers bounded | `max_field_size` / `max_headers` plumbed since 5fe9dfb64 (Mar 2026). | None. | +| 4.5 | Per-part body bounded | Per-iteration size check since 9cc4b917c (Mar 2026). | None. | +| 4.6 | Filename path traversal | None; verbatim return is the documented behaviour. | **User**: sanitise `BodyPartReader.filename` before opening / saving / reflecting (strip path components, control bytes, known-bad sequences). | +| 4.7 | Field-name pass-through | None. | **User**: `request.post()` keys come from attacker-controlled `name` parameters; do not feed them directly to filesystem / DB / template paths. | +| 4.8 | `_charset_` magic field | Behaviour follows the HTML5 spec for multipart form charset selection. | **Document the surprising behaviour: an attacker who can post any field can change how subsequent fields are decoded.** **User**: if your form-handling code makes decisions based on string content, be aware that the decode charset is attacker-influenceable. | +| 4.9 | CTE decoding | `BodyPartReader._decode_content_transfer` dispatches to `base64.b64decode` / `binascii.a2b_qp` / pass-through for `binary`/`8bit`/`7bit`, and raises `RuntimeError` on any other token. Both decoders raise on malformed input; decoded output is still subject to the per-part `client_max_size` cap. For base64, `BodyPartReader.read_chunk` extends each chunk read until its base64-significant character count is a multiple of 4, so mid-quartet splits cannot silently corrupt the decoded output. | None. | +| 4.10 | Content-Encoding decompression | Per-chunk `max_decompress_size`, per-part `client_max_size` cap. | Inherits the [§5.5](#55-compression-codecs) caveat about backend `max_length` honouring. | +| 4.11 | Outbound boundary collision | UUID4-hex default; HMAC-grade entropy means collision with random body bytes is statistically negligible. | **User**: callers supplying their own boundary must use a random, sufficiently long value. | +| 4.12 | `FormData` argument injection | `add_field` runs `name`, `filename`, and `content_type` through `_safe_header` (`http_writer.py:_safe_header`), rejecting `\r` / `\n` / `\x00`. `name` / `filename` are additionally percent-encoded by `content_disposition_header` (`helpers.py:content_disposition_header`) when `quote_fields=True` (default), so the `add_field` check is what closes the `quote_fields=False` opt-out path. | None. | +| 4.13 | Per-part header injection | `Payload._binary_headers` (`payload.py:_binary_headers`) now applies `_safe_header` to every name and value, raising `ValueError` if CR / LF / NUL is present before any byte reaches the wire. Covers all paths that mutate the headers dict (constructor, direct assignment, `set_content_disposition`). | None. | +| 4.14 | Temp-file lifetime | `tempfile.TemporaryFile()` is unlinked at creation on POSIX; closing the FD (whether explicit or via GC) reclaims the disk. | **User**: explicitly close `FileField`s you don't intend to consume to release the temp file promptly under high load. | + +**Past advisories / hardening (recap).** + +- **GHSA-5m98-qgg9-wh84 (CVE-2024-30251)** (3.9.4) — infinite loop in multipart + `_read_chunk_from_length` on a malformed POST body. +- **GHSA-jj3x-wxrx-4x23 (CVE-2025-69227)** (3.13.3) — `Request.post()` entered + an infinite loop on malformed bodies when `PYTHONOPTIMIZE` stripped `assert` + statements (asserts were doing real control-flow work in the + multipart reader). Fixed by replacing the assertions with explicit + checks. Code audit follow-up: any other `assert` used for control + flow should be identified and removed. +- **GHSA-6jhg-hg63-jvvf (CVE-2025-69228)** (3.13.3) — DoS via large + `Request.post()` payloads. +- **GHSA-2vrm-gr82-f7m5 (CVE-2026-34514)** (3.13.4) — + `FormData.add_field` rejects CR/LF in `content_type` (header + injection on outbound multipart). +- **GHSA-m5qp-6w8w-w647 (CVE-2026-34516)** (3.13.4) — `MultipartReader` + now honours `max_field_size` and `max_headers` from the underlying + protocol, plugging an unbounded per-part header memory growth path. +- **GHSA-3wq7-rqq7-wx6j (CVE-2026-34517)** (3.13.4) — `Request.post()` enforces + `client_max_size` during iteration rather than after buffering, + plugging a memory-blow-up on large form fields. +- **GHSA-m6qw-4cw2-hm4m (CVE-2026-50269)** (3.14.0) — + `Payload._binary_headers` now rejects CR / LF / NUL in any per-part + header name or value via `_safe_header`, closing the outbound + multipart header-injection path through `MultipartWriter.append(headers=...)` + and `Payload(headers=...)` that the main HTTP writer's `_safe_header` + check did not cover (threat 4.13). +- **PR #12721** (3.14.0) — companion to GHSA-m6qw-4cw2-hm4m: + `FormData.add_field` now rejects CR / LF / NUL in `name` and + `filename` (in addition to the previous `content_type` check), so the + `quote_fields=False` opt-out path can no longer be used to inject + extra `Content-Disposition` parameters or a fake-part break-out + (threat 4.12). +- **PR #12794** (3.14.1) — precautionary hardening: + multipart body parts whose `Content-Length` header is not a plain + decimal sequence (e.g. `+5`, `-1`, `1_0`) are now rejected, matching + the main request parser's strictness per RFC 9110 §8.6. + +These are all currently in place; this section assumes no regression.