h3llh0und

CVE-2026-111063: aiohttp and the cookies that belonged to no host

aiohttp's client keeps a special place for cookies that have no domain: they belong to no host, so they go to every host. I found that they also went over plain HTTP when marked Secure, because the jar checked that flag only for cookies that did have a host. I reported it in September; the fix shipped in 3.14.4, and GitHub issued the identifier in October.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every request in it went from the published aiohttp wheels on PyPI to a plain-HTTP aiohttp server listening on loopback on my own machine. The issue was reported privately through the project’s security advisory process. It is written up here after the advisory, the fixed release and the CVE record were public.

Here is a line of ordinary client code:

session = aiohttp.ClientSession(cookies={"session": token})

The cookie has a name and a value and nothing else. It has no Domain, because the application never said which host it was for. And it did not come from a response, so there is no URL to borrow a host from either.

aiohttp’s client has a documented answer for where such a cookie goes. Until the fix, the reference for CookieJar.update_cookies said:

“Regular cookies are coupled with server’s URL and are sent only to this server, shared ones are sent in every client request.”

A cookie with no host is a shared cookie, and a shared cookie is sent everywhere. That is a deliberate design, and a reasonable one: the application handed the cookie over without scoping it, so the library does not invent a scope.

The question my report asked was narrower. What happens when that cookie is marked Secure?

The bucket keyed by nothing

CookieJar stores cookies in buckets keyed by (domain, path). A cookie from a response lands under the response’s host. A cookie the application supplies directly, with no Domain attribute, lands under the empty key:

jar buckets:
   ('', ''):          ['shared_plain', 'shared_secure']
   ('127.0.0.1', ''): ['domained_secure']

When the session sends a request, CookieJar.filter_cookies() builds the Cookie header in two passes. It first emits everything in the ("", "") bucket, since shared cookies go to every request. Then it walks the buckets that match the request’s host and path, and applies the per-cookie rules: host-only cookies stay on their host, path prefixes must match, and Secure cookies are held back from a plaintext request.

That last rule was one line, in the second pass:

if is_not_secure and cookie["secure"]:
    continue

is_not_secure is computed once at the top of the function, from the request’s scheme, and it was in scope for both passes. Only the second one read it. In the whole of cookiejar.py, cookie["secure"] appeared on that line and nowhere else.

So Secure meant something for every cookie except the ones in the bucket with no host. For those, the flag was stored, preserved and never consulted.

Two kinds of missing scope

A shared cookie with Secure set has two kinds of scope missing, and the application gave up only one of them on purpose.

Leaving out Domain is a choice about where: send this to every host. aiohttp documented that behaviour, and an application that does not want it can set a domain.

Setting Secure is a choice about how: never put this on the wire in clear text. Nothing about having no host cancels that. A token that is fine to send to any host the session talks to over TLS is not fine to send to a network path anyone can read. The application said so explicitly, and the jar dropped the flag without a warning.

Because the shared cookie also has no host, the leak is not limited to the server it was meant for. Any http:// URL the session requests receives it, and that includes a host reached through a redirect.

This caps the finding, and the report said so plainly rather than leave it for the maintainers to find.

A remote server cannot plant a shared cookie. When a response sets a cookie, the jar is updated with the response URL, and update_cookies() assigns the response host as the cookie’s domain whenever the URL has one. The client always has a host to pass. A Set-Cookie from the attacker’s server lands in the attacker’s own bucket.

So the shared bucket is filled only by the application:

The attacker does not choose the cookie. What the attacker gets is that the application’s Secure flag did not do what it says, which matters to anyone positioned on a plaintext path the session crosses.

I scored that at CVSS 3.1 AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N, 3.7 Low. Attack complexity is High because two conditions have to hold, and the attacker controls neither: the application has to supply a domain-less Secure cookie, and the session has to make a plaintext request. Confidentiality is Low because the leaked value is whatever the application put there, and assuming it is a credential would have been inflating. The anchor was aiohttp’s own GHSA-4m7w-qmgq-4wj5, a TLS hostname override silently ignored when connections are reused. It has the same shape: a security setting the application supplied, which the client does not honour. aiohttp rated that Low.

Since the bucket existed

This was not a regression. The shared bucket arrived in commit 0199e4a6 in September 2016, the first version to ship it was 1.0.0, and the early continue for shared cookies was already there, ahead of the Secure check:

            # Send shared cookies
            if not domain:
                filtered[name] = cookie.value
                continue

The function looks different in 3.14.3, with a morsel cache and two named loops, but the order is the same: the shared cookies are emitted first, and the Secure check comes only after them. The test that would have caught it, test_secure_filter, used a shared cookie that was not Secure.

The run

The proof put three cookies in one jar and sent plaintext requests to a loopback aiohttp server that echoes the Cookie header back:

For this post I ran the same script against the published wheels on both sides of the fix, adding a second hostname and a per-request cookie:

aiohttp 3.14.3
GET http://127.0.0.1:<port>/   Cookie: shared_plain=NOT-SECRET; shared_secure=SECRET-SHARED
  TARGET  shared_secure   over http: SENT
  CONTROL domained_secure over http: withheld
  CONTROL shared_plain    over http: SENT
GET http://localhost:<port>/   Cookie: shared_plain=NOT-SECRET; shared_secure=SECRET-SHARED
per-request cookies= Secure over http: SENT

aiohttp 3.14.4
GET http://127.0.0.1:<port>/   Cookie: shared_plain=NOT-SECRET
  TARGET  shared_secure   over http: withheld
  CONTROL domained_secure over http: withheld
  CONTROL shared_plain    over http: SENT
GET http://localhost:<port>/   Cookie: shared_plain=NOT-SECRET
per-request cookies= Secure over http: withheld

The controls held on both versions. The Secure cookie with a domain was withheld over HTTP, so the jar does enforce the flag when the second pass runs. The plain shared cookie was sent, so the shared pass ran. On 3.14.3 the target went out to both hostnames, and so did a Secure cookie passed per request. On 3.14.4 none of them did. The plain shared cookie still reaches both hosts on 3.14.4. That is the documented behaviour of a cookie with no host, and the fix rightly leaves it alone.

I did not test the https:// side beyond reading the fix’s own tests, which assert that the Secure shared cookie is sent over HTTPS.

The fix, in public

I sent the report on 12 September. On 27 September Sam Bull (Dreamsorcerer) accepted it, and the advisory’s only comment was a link to a public pull request, #13830, “Fix secure shared cookie”, opened shortly before. It added the check at the top of the shared pass:

        # Send shared cookie
        key = ("", "")
        for c in self._cookies[key].values():
            if is_not_secure and c["secure"]:
                continue

            # Check cache first

The report had pointed out where the check had to go: before the morsel cache lookup, not after it. Otherwise a cookie cached by an earlier HTTPS request would be served from the cache on the next plaintext one. The new test covers exactly that sequence: http, then https, then http again. The pull request also changed the documentation sentence quoted at the top: shared cookies are sent in every client request, “except that shared cookies marked Secure are only sent over encrypted connections”.

It was merged into master that evening and backported to the 3.15 and 3.14 branches as #13831 and #13832; the 3.14 commit is 999c356d. Later the same evening a second pull request, #13833, passed the jar’s treat_as_secure_origin setting through to the temporary jar used for per-request cookies. Without it, a per-request Secure cookie would now be withheld even from origins the application had declared secure.

Both fixes shipped in aiohttp 3.14.4 on 5 October, listed among the release’s bug fixes. The advisory was published two days later.

Low on the advisory, Medium on the record

Dreamsorcerer published GHSA-494j-54xm-rwp3 on 7 October with severity Low, no CVSS vector, CWE-311 and CWE-1004, affected <= 3.14.3, patched in 3.14.4, and credit to me as reporter. A CVE was requested from GitHub the same day. GitHub reserved CVE-2026-111063 on 15 October and published the record on 19 October.

The record scores it in CVSS 4.0, AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N, 6.3 Medium. The two conditions I put under attack complexity appear there as attack requirements instead, and 4.0 weighs those differently, so the same facts land a band higher. Like every GitHub-issued record I have seen, it has no credits field. The credit lives on the advisory.

The record’s description also goes one step further than the report did: “An attacker can consequently cause a shared cookie marked Secure to be sent with an unencrypted request.” As far as I could find, an attacker cannot cause it. The application has to put the cookie in the bucket with no host, and the network has to carry a plaintext request. The attacker can only be there when both happen. That is the whole size of this bug, and it is also why it was worth fixing: an explicit Secure should not depend on whether the cookie also had a domain.


Advisory GHSA-494j-54xm-rwp3 · CVE CVE-2026-111063 · CVSS none on the advisory (severity Low), 6.3 Medium (v4.0) on the record · Fix #13830, 999c356d on 3.14 · Affected >= 1.0.0, <= 3.14.3 · Fixed 3.14.4

Reported 12 September · accepted and fixed in public 27 September · 3.14.4 released 5 October · advisory published 7 October, crediting me as reporter, with a CVE requested from GitHub · CVE record published by GitHub 19 October.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und