diff mbox series

[meta-python,wrynose,6/7] python3-aiohttp: fix CVE-2026-111063

Message ID 20261106072702.502813-7-dkelaiya@cisco.com
State New
Headers show
Series python3-aiohttp: Security fixes | expand

Commit Message

From: Darsh Kelaiya <dkelaiya@cisco.com>

Backport the Secure shared-cookie fix [1] to aiohttp 3.13.5, as
identified by [3]. Apply the Secure check before looking up cached
shared morsels so an earlier HTTPS request cannot bypass it.

Include the upstream trusted-origin correction [2] so per-request
cookies retain the session jar's treat_as_secure_origin policy.
The default property keeps existing custom cookie jars compatible.
Include the upstream cookie and client regression tests.

[1] https://github.com/aio-libs/aiohttp/commit/999c356d61e966faffa97b06ab0d0455226d36c5
[2] https://github.com/aio-libs/aiohttp/commit/95081f8a353be2873643ab6dd74904ca413e45e0
[3] https://github.com/aio-libs/aiohttp/security/advisories/GHSA-494j-54xm-rwp3

Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
---
 .../python3-aiohttp/CVE-2026-111063_p1.patch  | 137 ++++++++++
 .../python3-aiohttp/CVE-2026-111063_p2.patch  | 244 ++++++++++++++++++
 .../python/python3-aiohttp_3.13.5.bb          |   2 +
 3 files changed, 383 insertions(+)
 create mode 100644 meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111063_p1.patch
 create mode 100644 meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111063_p2.patch
diff mbox series

Patch

diff --git a/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111063_p1.patch b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111063_p1.patch
new file mode 100644
index 0000000000..f66cdf7d53
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111063_p1.patch
@@ -0,0 +1,137 @@ 
+From 999c356d61e966faffa97b06ab0d0455226d36c5 Mon Sep 17 00:00:00 2001
+From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com>
+Date: Sun, 27 Sep 2026 21:27:34 +0100
+Subject: [PATCH] [PR #13830/e11d2836 backport][3.14] Fix secure shared cookie
+ (#13832)
+
+**This is a backport of PR #13830 as merged into master
+(e11d2836203a21bec59095498e578d37801027e7).**
+
+Co-authored-by: Sam Bull <git@sambull.org>
+
+CVE: CVE-2026-111063
+Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/999c356d61e966faffa97b06ab0d0455226d36c5]
+
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ CHANGES/13830.bugfix.rst        |  1 +
+ aiohttp/cookiejar.py            |  3 +++
+ docs/client_reference.rst       |  3 ++-
+ docs/spelling_wordlist.txt      |  1 +
+ tests/test_client_functional.py |  9 +++++++--
+ tests/test_cookiejar.py         | 28 ++++++++++++++++++++++++++++
+ 6 files changed, 42 insertions(+), 3 deletions(-)
+ create mode 100644 CHANGES/13830.bugfix.rst
+
+diff --git a/CHANGES/13830.bugfix.rst b/CHANGES/13830.bugfix.rst
+new file mode 100644
+index 0000000..2a39769
+--- /dev/null
++++ b/CHANGES/13830.bugfix.rst
+@@ -0,0 +1 @@
++Fixed ``CookieJar.filter_cookies()`` sending shared cookies (cookies without a ``Domain`` attribute) marked ``Secure`` over unencrypted connections -- by :user:`Dreamsorcerer`.
+diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py
+index 6712b90..78630b5 100644
+--- a/aiohttp/cookiejar.py
++++ b/aiohttp/cookiejar.py
+@@ -471,6 +471,9 @@ class CookieJar(AbstractCookieJar):
+         # Send shared cookie
+         key = ("", "")
+         for c in self._cookies[key].values():
++            if is_not_secure and c["secure"]:
++                continue
++
+             # Check cache first
+             if c.key in self._morsel_cache[key]:
+                 filtered[c.key] = self._morsel_cache[key][c.key]
+diff --git a/docs/client_reference.rst b/docs/client_reference.rst
+index 63ee375..24f9088 100644
+--- a/docs/client_reference.rst
++++ b/docs/client_reference.rst
+@@ -2464,7 +2464,8 @@ Utilities
+       :param ~yarl.URL response_url: URL of response, ``None`` for *shared
+          cookies*.  Regular cookies are coupled with server's URL and
+          are sent only to this server, shared ones are sent in every
+-         client request.
++         client request (except that shared cookies marked ``Secure``
++         are only sent over encrypted connections).
+ 
+    .. method:: filter_cookies(request_url)
+ 
+diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
+index e429f8c..e04017c 100644
+--- a/docs/spelling_wordlist.txt
++++ b/docs/spelling_wordlist.txt
+@@ -350,6 +350,7 @@ un
+ unawaited
+ unclosed
+ undercounting
++unencrypted
+ unescaped
+ unhandled
+ unicode
+diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py
+index 3415b83..a92d008 100644
+--- a/tests/test_client_functional.py
++++ b/tests/test_client_functional.py
+@@ -2843,12 +2843,17 @@ async def test_morsel_with_attributes(aiohttp_client) -> None:
+     c = http.cookies.Morsel()
+     c.set("test3", "456", "456")
+     c["httponly"] = True
+-    c["secure"] = True
+     c["max-age"] = 1000
+ 
++    # A Secure shared cookie must be withheld entirely: the test server
++    # is plain http, so it must not reach the handler at all.
++    c2: http.cookies.Morsel[str] = http.cookies.Morsel()
++    c2.set("test4", "789", "789")
++    c2["secure"] = True
++
+     app = web.Application()
+     app.router.add_get("/", handler)
+-    client = await aiohttp_client(app, cookies={"test2": c})
++    client = await aiohttp_client(app, cookies={"test2": c, "test4": c2})
+ 
+     async with client.get("/") as resp:
+         assert 200 == resp.status
+diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py
+index 9a061a3..d4bfdcc 100644
+--- a/tests/test_cookiejar.py
++++ b/tests/test_cookiejar.py
+@@ -1155,6 +1155,34 @@ async def test_treat_as_secure_origin() -> None:
+     assert len(filtered_cookies) == 1
+ 
+ 
++async def test_treat_as_secure_origin_sends_secure_shared_cookie() -> None:
++    endpoint = URL("http://127.0.0.1/")
++
++    jar = CookieJar(unsafe=True, treat_as_secure_origin=[endpoint])
++    jar.update_cookies(SimpleCookie("shared-secure-cookie=first; Secure;"))
++
++    filtered_cookies = jar.filter_cookies(request_url=endpoint)
++    assert "shared-secure-cookie" in filtered_cookies
++
++
++async def test_filter_cookies_with_secure_shared_cookie() -> None:
++    """Secure shared cookies (no Domain attribute) must not be sent over cleartext."""
++    jar = CookieJar()
++    jar.update_cookies(
++        SimpleCookie("shared-secure-cookie=first; Secure; shared-cookie=second;")
++    )
++
++    filtered = jar.filter_cookies(URL("http://example.com/"))
++    assert set(filtered.keys()) == {"shared-cookie"}
++
++    filtered = jar.filter_cookies(URL("https://example.com/"))
++    assert set(filtered.keys()) == {"shared-cookie", "shared-secure-cookie"}
++
++    # The morsel cached by the https request must not bypass the Secure check.
++    filtered = jar.filter_cookies(URL("http://example.com/"))
++    assert set(filtered.keys()) == {"shared-cookie"}
++
++
+ async def test_filter_cookies_does_not_leak_memory() -> None:
+     """Test that filter_cookies doesn't create empty cookie entries.
+ 
+-- 
+2.35.6
diff --git a/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111063_p2.patch b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111063_p2.patch
new file mode 100644
index 0000000000..b71f531a36
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111063_p2.patch
@@ -0,0 +1,244 @@ 
+From 95081f8a353be2873643ab6dd74904ca413e45e0 Mon Sep 17 00:00:00 2001
+From: Sam Bull <git@sambull.org>
+Date: Mon, 28 Sep 2026 00:38:13 +0100
+Subject: [PATCH] Fix treat_as_secure_origin on request cookies (#13833)
+ (#13836)
+
+(cherry picked from commit 6bc84990f2a6429667f741d97f5da22884b8a8d3)
+
+CVE: CVE-2026-111063
+Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/95081f8a353be2873643ab6dd74904ca413e45e0]
+
+Backport Changes:
+- Apply to aiohttp 3.13.5 and retain its Union type spelling.
+- Pass trusted origins through the existing quote_cookie constructor
+  context without adding the newer unsafe inspection API.
+- Add the property documentation beside the existing attributes;
+  the newer public cookie-inspection sections are absent.
+
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ CHANGES/13833.bugfix.rst        |  1 +
+ aiohttp/abc.py                  |  5 ++++
+ aiohttp/client.py               |  5 +++-
+ aiohttp/cookiejar.py            | 26 +++++++++++------
+ docs/client_reference.rst       | 13 +++++++--
+ tests/test_client_functional.py | 49 +++++++++++++++++++++++++++++++++
+ tests/test_cookiejar.py         |  3 +-
+ 7 files changed, 89 insertions(+), 13 deletions(-)
+ create mode 100644 CHANGES/13833.bugfix.rst
+
+diff --git a/CHANGES/13833.bugfix.rst b/CHANGES/13833.bugfix.rst
+new file mode 100644
+index 0000000..2d0f96c
+--- /dev/null
++++ b/CHANGES/13833.bugfix.rst
+@@ -0,0 +1 @@
++Fixed per-request cookies (the ``cookies`` argument of a request method) marked ``Secure`` not being sent to origins listed in ``CookieJar``'s ``treat_as_secure_origin`` -- by :user:`Dreamsorcerer`.
+diff --git a/aiohttp/abc.py b/aiohttp/abc.py
+index faf0957..82afc6c 100644
+--- a/aiohttp/abc.py
++++ b/aiohttp/abc.py
+@@ -182,6 +182,11 @@ class AbstractCookieJar(Sized, IterableBase):
+     def quote_cookie(self) -> bool:
+         """Return True if cookies should be quoted."""
+ 
++    @property
++    def treat_as_secure_origin(self) -> frozenset[URL]:
++        """Return origins considered secure even over cleartext connections."""
++        return frozenset()
++
+     @abstractmethod
+     def clear(self, predicate: Optional[ClearCookiePredicate] = None) -> None:
+         """Clear all cookies if no predicate is passed."""
+diff --git a/aiohttp/client.py b/aiohttp/client.py
+index f404b55..1eae60a 100644
+--- a/aiohttp/client.py
++++ b/aiohttp/client.py
+@@ -692,7 +692,10 @@ class ClientSession:
+ 
+                     if cookies is not None:
+                         tmp_cookie_jar = CookieJar(
+-                            quote_cookie=self._cookie_jar.quote_cookie
++                            quote_cookie=self._cookie_jar.quote_cookie,
++                            treat_as_secure_origin=frozenset(
++                                self._cookie_jar.treat_as_secure_origin
++                            ),
+                         )
+                         tmp_cookie_jar.update_cookies(cookies)
+                         req_cookies = tmp_cookie_jar.filter_cookies(url)
+diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py
+index 78630b5..3b2c490 100644
+--- a/aiohttp/cookiejar.py
++++ b/aiohttp/cookiejar.py
+@@ -129,7 +129,7 @@ class CookieJar(AbstractCookieJar):
+         *,
+         unsafe: bool = False,
+         quote_cookie: bool = True,
+-        treat_as_secure_origin: Union[StrOrURL, List[StrOrURL], None] = None,
++        treat_as_secure_origin: Union[StrOrURL, Iterable[StrOrURL], None] = None,
+         loop: Optional[asyncio.AbstractEventLoop] = None,
+     ) -> None:
+         super().__init__(loop=loop)
+@@ -144,17 +144,20 @@ class CookieJar(AbstractCookieJar):
+         self._unsafe = unsafe
+         self._quote_cookie = quote_cookie
+         if treat_as_secure_origin is None:
+-            treat_as_secure_origin = []
++            self._treat_as_secure_origin: frozenset[URL] = frozenset()
+         elif isinstance(treat_as_secure_origin, URL):
+-            treat_as_secure_origin = [treat_as_secure_origin.origin()]
++            self._treat_as_secure_origin = frozenset({treat_as_secure_origin.origin()})
+         elif isinstance(treat_as_secure_origin, str):
+-            treat_as_secure_origin = [URL(treat_as_secure_origin).origin()]
++            self._treat_as_secure_origin = frozenset(
++                {URL(treat_as_secure_origin).origin()}
++            )
+         else:
+-            treat_as_secure_origin = [
+-                URL(url).origin() if isinstance(url, str) else url.origin()
+-                for url in treat_as_secure_origin
+-            ]
+-        self._treat_as_secure_origin = treat_as_secure_origin
++            self._treat_as_secure_origin = frozenset(
++                {
++                    URL(url).origin() if isinstance(url, str) else url.origin()
++                    for url in treat_as_secure_origin
++                }
++            )
+         self._expire_heap: List[Tuple[float, Tuple[str, str, str]]] = []
+         self._expirations: Dict[Tuple[str, str, str], float] = {}
+ 
+@@ -162,6 +165,11 @@ class CookieJar(AbstractCookieJar):
+     def quote_cookie(self) -> bool:
+         return self._quote_cookie
+ 
++    @property
++    def treat_as_secure_origin(self) -> frozenset[URL]:
++        """Return origins considered secure even over cleartext connections."""
++        return self._treat_as_secure_origin
++
+     def save(self, file_path: PathLike) -> None:
+         """Save cookies to a file using JSON format.
+ 
+diff --git a/docs/client_reference.rst b/docs/client_reference.rst
+index 24f9088..353047d 100644
+--- a/docs/client_reference.rst
++++ b/docs/client_reference.rst
+@@ -2441,7 +2441,7 @@ Utilities
+       .. versionadded:: 3.7
+ 
+    :param treat_as_secure_origin: (optional) Mark origins as secure
+-                                  for cookies marked as Secured. Possible types are
++                                  for cookies marked as Secured.
+ 
+                                   Possible types are:
+ 
+@@ -2465,7 +2465,8 @@ Utilities
+          cookies*.  Regular cookies are coupled with server's URL and
+          are sent only to this server, shared ones are sent in every
+          client request (except that shared cookies marked ``Secure``
+-         are only sent over encrypted connections).
++         are only sent over encrypted connections or to origins listed
++         in *treat_as_secure_origin*).
+ 
+    .. method:: filter_cookies(request_url)
+ 
+@@ -2521,6 +2522,14 @@ Utilities
+ 
+       .. versionadded:: 4.0
+ 
++   .. attribute:: treat_as_secure_origin
++
++      A :class:`frozenset` of :class:`~yarl.URL` origins that are
++      treated as secure even when the connection is not encrypted, as
++      configured by the *treat_as_secure_origin* parameter.
++
++      .. versionadded:: 3.14.4
++
+ 
+ .. class:: DummyCookieJar(*, loop=None)
+ 
+diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py
+index a92d008..e0bb02e 100644
+--- a/tests/test_client_functional.py
++++ b/tests/test_client_functional.py
+@@ -2859,6 +2859,55 @@ async def test_morsel_with_attributes(aiohttp_client) -> None:
+         assert 200 == resp.status
+ 
+ 
++async def test_request_secure_cookie_treat_as_secure_origin(
++    aiohttp_server: AiohttpServer, aiohttp_client: AiohttpClient
++) -> None:
++    """Per-request Secure cookies must honor the session jar's trusted origins."""
++
++    async def handler(request: web.Request) -> web.Response:
++        assert request.cookies.get("auth") == "token"
++        return web.Response()
++
++    app = web.Application()
++    app.router.add_get("/", handler)
++    server = await aiohttp_server(app)
++
++    jar = aiohttp.CookieJar(unsafe=True, treat_as_secure_origin=[server.make_url("/")])
++    client = await aiohttp_client(server, cookie_jar=jar)  # type: ignore[var-annotated]
++
++    c: http.cookies.Morsel[str] = http.cookies.Morsel()
++    c.set("auth", "token", "token")
++    c["secure"] = True
++
++    async with client.get("/", cookies={"auth": c}) as resp:
++        assert resp.status == 200
++
++
++async def test_request_secure_cookie_not_sent_over_http(
++    aiohttp_client: AiohttpClient,
++) -> None:
++    """A per-request Secure cookie is only sent to the jar's trusted origins."""
++
++    async def handler(request: web.Request) -> web.Response:
++        assert request.cookies.keys() == {"plain"}
++        return web.Response()
++
++    app = web.Application()
++    app.router.add_get("/", handler)
++    # The trusted origin never matches the test server, so the Secure
++    # cookie must still be withheld from this plain-http request.
++    jar = aiohttp.CookieJar(treat_as_secure_origin=[URL("http://example.com")])
++    client = await aiohttp_client(app, cookie_jar=jar)
++
++    c: http.cookies.Morsel[str] = http.cookies.Morsel()
++    c.set("auth", "token", "token")
++    c["secure"] = True
++
++    cookies: dict[str, str | http.cookies.Morsel[str]] = {"auth": c, "plain": "ok"}
++    async with client.get("/", cookies=cookies) as resp:
++        assert resp.status == 200
++
++
+ async def test_set_cookies(
+     aiohttp_client: AiohttpClient, caplog: pytest.LogCaptureFixture
+ ) -> None:
+diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py
+index d4bfdcc..4c7107c 100644
+--- a/tests/test_cookiejar.py
++++ b/tests/test_cookiejar.py
+@@ -830,6 +830,7 @@ async def test_dummy_cookie_jar() -> None:
+     cookie = SimpleCookie("foo=bar; Domain=example.com;")
+     dummy_jar = DummyCookieJar()
+     assert dummy_jar.quote_cookie is True
++    assert dummy_jar.treat_as_secure_origin == frozenset()
+     assert len(dummy_jar) == 0
+     dummy_jar.update_cookies(cookie)
+     assert len(dummy_jar) == 0
+@@ -1134,7 +1135,7 @@ async def test_pickle_format(cookies_to_send) -> None:
+ )
+ async def test_treat_as_secure_origin_init(url) -> None:
+     jar = CookieJar(unsafe=True, treat_as_secure_origin=url)
+-    assert jar._treat_as_secure_origin == [URL("http://127.0.0.1")]
++    assert jar.treat_as_secure_origin == frozenset({URL("http://127.0.0.1")})
+ 
+ 
+ async def test_treat_as_secure_origin() -> None:
+-- 
+2.35.6
diff --git a/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb b/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb
index 10817e6f34..c6167ef607 100644
--- a/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb
+++ b/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb
@@ -29,6 +29,8 @@  SRC_URI += " \
     file://CVE-2026-111065.patch \
     file://CVE-2026-111748_p1.patch \
     file://CVE-2026-111748_p2.patch \
+    file://CVE-2026-111063_p1.patch \
+    file://CVE-2026-111063_p2.patch \
 "
 
 CVE_PRODUCT = "aiohttp:aiohttp"