CVE-2026-47265 — aiohttp per-request cookie leaked on cross-origin redirect¶
Disclosure
Per-request cookies passed via the cookies= keyword to aiohttp.ClientSession are forwarded to foreign origins after a cross-origin redirect, leaking credentials that were only meant for the original destination.
| Field | Value |
|---|---|
| Project | aiohttp |
| Affected component | aio-libs/aiohttp — ClientSession per-request cookies |
| Severity | MEDIUM |
| CVSS 4.0 | 6.6 Medium (AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N) |
| CWE | CWE-346 — Origin Validation Error |
| Affected versions | < 3.14.0 |
| Fixed version | 3.14.0 (released 2026-06-01) |
| Advisory | GHSA-hg6j-4rv6-33pg / CVE-2026-47265 |
1. Vulnerability overview¶
aiohttp is a widely used async HTTP client/server framework for Python. When an application passes per-request cookies via the cookies= keyword argument to ClientSession.get(), .post(), or the underlying ._request() method, and the server responds with a cross-origin redirect, those cookies are forwarded to the foreign destination. An attacker who can influence the redirect target (through an open redirect on the first-hop server, SSRF, a compromised CDN edge, or a similar mechanism) receives the victim's per-request cookie values in the Cookie header of the redirected request.
Session-level cookies stored in the CookieJar are not affected; the jar's domain filter correctly withholds them from foreign origins. Only the cookies= per-request parameter is vulnerable.
Root cause¶
File: aiohttp/client.py
Function: ClientSession._request — the while True redirect loop (~line 637 in v3.13.5)
On each iteration of the redirect loop, the method rebuilds all_cookies and re-attaches any per-request cookies:
# Line 690-699 — runs on every loop iteration, including after a redirect
all_cookies = self._cookie_jar.filter_cookies(url)
if cookies is not None: # <-- cookies still set after cross-origin redirect
tmp_cookie_jar = CookieJar(
quote_cookie=self._cookie_jar.quote_cookie
)
tmp_cookie_jar.update_cookies(cookies)
req_cookies = tmp_cookie_jar.filter_cookies(url)
if req_cookies:
all_cookies.load(req_cookies) # <-- attacker-controlled origin receives cookies
When the redirect crosses an origin boundary, the code clears auth, headers[AUTHORIZATION], headers[COOKIE], and headers[PROXY_AUTHORIZATION], but never resets the cookies variable:
# Line 893-897 — cross-origin redirect detected
if url.origin() != redirect_origin:
auth = None
headers.pop(hdrs.AUTHORIZATION, None)
headers.pop(hdrs.COOKIE, None)
headers.pop(hdrs.PROXY_AUTHORIZATION, None)
# BUG: cookies = None is missing here
Because cookies is not cleared, the next iteration re-enters the if cookies is not None: block and appends the per-request cookie to the outgoing request for the foreign origin.
Patch¶
The fix, applied in commit f54c40851b0d6c4bbdab97ba518a223adda32478 (cherry-picked from d57efb05f5073071ceb2d3b35d72d9d0bc4512a2, PR #12550 "Drop cookies on redirect"), is a single line:
--- a/aiohttp/client.py
+++ b/aiohttp/client.py
@@ -971,6 +971,7 @@ async def _connect_and_send_request(
if url.origin() != redirect_origin:
auth = None
+ cookies = None
headers.pop(hdrs.AUTHORIZATION, None)
headers.pop(hdrs.COOKIE, None)
headers.pop(hdrs.PROXY_AUTHORIZATION, None)
Setting cookies = None causes the guard if cookies is not None: on the next loop iteration to be false, so per-request cookies are never appended to the outgoing request destined for the foreign origin.
2. Vulnerable environment¶
The reproduction environment is a three-container Docker Compose stack on a private bridge network. All three services share a single image built from python:3.12-slim-bookworm; the vulnerable aiohttp version is pinned via a build argument.
Download all environment files: env.zip
Individual files: - env/Dockerfile - env/docker-compose.yml - env/config/victim_entrypoint.sh - env/config/victim_app.py - env/config/firsthop_app.py - env/config/collector_app.py
Topology¶
| Container | Role | Network | Published port |
|---|---|---|---|
cve-2026-47265-victim |
Vulnerable aiohttp 3.13.5 client; generates a fresh secret on boot and exposes a /trigger control endpoint |
lab bridge |
127.0.0.1:9000 → 9000 |
cve-2026-47265-firsthop |
Origin A — the origin the per-request cookie is scoped to; /redirect issues a 302 cross-origin redirect to the collector |
lab bridge |
internal only |
cve-2026-47265-collector |
Origin B (foreign) — records every inbound Cookie header to /loot/cookie_header.log |
lab bridge |
internal only |
The victim requests http://firsthop:80/redirect; firsthop responds with 302 Location: http://collector:80/collect. Because the hostnames differ, aiohttp evaluates url.origin() != redirect_origin as true and takes the vulnerable branch.
Standing up the environment¶
Confirming you have the vulnerable version¶
Once the containers are healthy, verify the pinned version, the presence of the fresh secret, and the empty loot file:
curl -s http://127.0.0.1:9000/health \
&& docker exec cve-2026-47265-victim sh -c 'test -s /secret/cookie_value && echo secret-present' \
&& docker exec cve-2026-47265-collector sh -c 'test -f /loot/cookie_header.log && echo loot-ready'
Expected output: ok, secret-present, loot-ready.
Check the pinned aiohttp version inside the victim container:
Expected: Version: 3.13.5.
The secret at /secret/cookie_value inside the victim container is a random UUID hex string generated by victim_entrypoint.sh on each container start. It is not baked into the image and changes on every restart.
3. How to exploit¶
The exploit is a single shell script (exploit/run.sh) that calls the victim's trigger endpoint and reads the collector's record of the inbound Cookie header.
Download the exploit: exploit.zip
Prerequisites¶
Verify the environment is healthy and the loot file is empty before starting:
curl -s http://127.0.0.1:9000/health \
&& docker exec cve-2026-47265-victim sh -c 'test -s /secret/cookie_value && echo secret-present' \
&& docker exec cve-2026-47265-collector sh -c 'test -f /loot/cookie_header.log && echo loot-ready'
Step 1 — Record the ground-truth secret¶
Read the fresh secret from the victim's protected store. This is the value the exploit must not supply itself:
On the verified run this returned 691e6dd64d84498595e22bf87cb860c3.
Step 2 — Run the exploit¶
The script calls curl against the victim's /trigger endpoint. The victim's aiohttp client issues a GET http://firsthop:80/redirect with cookies={"session": <secret>}. Firsthop returns 302 Location: http://collector:80/collect. Because cookies is never cleared on a cross-origin redirect, the vulnerable aiohttp client re-attaches the per-request cookie to the request sent to the foreign collector origin.
Step 3 — Observe the leaked cookie¶
The script prints the collector's record of the inbound Cookie header. On the verified run the output was:
[*] Triggering victim first-hop request -> cross-origin redirect
victim issued first-hop request with a per-request cookie; final response from chain: 'collected'
[*] Trigger returned; reading foreign-origin (collector) Cookie-header record
=== collector inbound Cookie header(s) (cross-origin observation point) ===
session=691e6dd64d84498595e22bf87cb860c3
=== end ===
What proves it worked¶
The proof is an independent observation, not the exploit's own output. The verifier reads the collector's loot file directly via docker exec against the collector container and compares it against the victim-side ground truth, also read through a separate docker exec:
docker exec cve-2026-47265-victim cat /secret/cookie_value
# -> 691e6dd64d84498595e22bf87cb860c3
docker exec cve-2026-47265-collector cat /loot/cookie_header.log
# -> session=691e6dd64d84498595e22bf87cb860c3
The two values match: 691e6dd64d84498595e22bf87cb860c3 (victim's protected store) equals 691e6dd64d84498595e22bf87cb860c3 (collector's inbound Cookie header). The exploit never touched the secret; it only called /trigger and read a file the collector wrote from its own incoming HTTP headers. A secret scoped to origin A (firsthop) appeared verbatim at the unrelated origin B (collector), which is only possible because the vulnerable redirect branch forwarded the per-request cookie.
Secret freshness was confirmed by restarting the victim and observing a new value:
S1 = 296cd2d18c9f49c8856ce3cb8459736a (first boot)
S2 = 691e6dd64d84498595e22bf87cb860c3 (after restart)
S1 != S2 confirms the secret is unpredictable and cannot be guessed or replayed.
Teardown¶
When you are done, stop and remove all containers and volumes:
4. Security advice¶
Remediation¶
Upgrade aiohttp to 3.14.0 or later. The fix is a one-line change (cookies = None) in the cross-origin redirect branch of ClientSession._request. Once applied, the if cookies is not None: guard prevents per-request cookies from being appended to requests destined for foreign origins.
Workaround¶
If an immediate upgrade is not possible, pass cookies as an explicit Cookie header in the headers= dictionary rather than the cookies= keyword argument. The cross-origin redirect handler already clears headers[COOKIE], so this path is safe in all affected versions:
# Safe in all versions — the Cookie header is explicitly cleared on cross-origin redirect
await session.get(url, headers={"Cookie": "session=SECRET"})
# Vulnerable in aiohttp < 3.14.0 — cookies= kwarg is NOT cleared
await session.get(url, cookies={"session": "SECRET"})
This workaround trades the convenience of the cookies= API for an explicit header string; evaluate whether that is acceptable for your use case.
References¶
- GHSA-hg6j-4rv6-33pg advisory — primary advisory; summary, impact, workaround, patch link
- GitHub advisory API JSON — affected versions (
< 3.14.0), first patched (3.14.0), CWE-346, CVSS 4.0 score 6.6 - Patch commit f54c408 — the one-line fix (
cookies = None), cherry-picked fromd57efb05to the 3.x stable branch - Original PR #12550 — "Drop cookies on redirect", authored by Sam Bull (Dreamsorcerer)
- NVD CVE-2026-47265 — NVD entry confirming CWE-346, CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N (6.6 Medium)
- aiohttp v3.14.0 release — release date 2026-06-01, confirms fixed version
- aiohttp changelog — 3.14.0 entry: "Fixed per-request
cookiesnot being dropped on cross-origin redirects" (issue #12550)