Repository navigation
fix(deps): update dependency tornado to v6.5.9 [security] - #503
Open
renovate[bot] wants to merge 1 commit into
Open
renovate[bot] wants to merge 1 commit into
renovate[bot] wants to merge 1 commit into
Conversation
renovate
Bot
force-pushed
the
renovate/pypi-tornado-vulnerability
branch
2 times, most recently
from
September 3, 2026 14:13
3f65775 to
ed75e7b
Compare
renovate
Bot
force-pushed
the
renovate/pypi-tornado-vulnerability
branch
from
October 1, 2026 16:44
ed75e7b to
ae9f386
Compare
renovate
Bot
force-pushed
the
renovate/pypi-tornado-vulnerability
branch
from
October 6, 2026 17:07
ae9f386 to
d6ee605
Compare
renovate
Bot
force-pushed
the
renovate/pypi-tornado-vulnerability
branch
from
October 7, 2026 21:33
d6ee605 to
0f6d62e
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR contains the following updates:
==6.5.7→==6.5.9Tornado: Incomplete fix for CVE-2026-35536: cookie attribute injection re-opened via the legacy case-insensitive
**kwargspath inset_cookieGHSA-wwv5-g3v4-889x
More information
Details
Summary
The CVE-2026-35536 fix added a validation loop that rejects
[\x00-\x20\x3b\x7f], but only for thehardcoded lowercase keys
name/domain/path/samesite. The still-live deprecated**kwargspathwrites attacker-supplied attribute values straight into the
Morselwith no validation, and becauseMorsel.__setitem__is case-insensitive, a capitalized kwarg (Domain=,Path=,SameSite=,Max-Age=)routes to the same reserved attribute while bypassing the loop — re-opening
;-delimited attribute injection.The patch's regression test (
SetCookieForbiddenCharHandler) only exercises the four named params, never the**kwargspath, so the gap is not regression-covered.Affected code
tornado/web.py→RequestHandler.set_cookie: the validation loop covers only the lowercase named args;the trailing
if kwargs:loop doesmorsel[k] = vwith no character validation.Steps to reproduce
GET /upper(usesDomain=kwarg) emitsSet-Cookie: c_upper=v; Domain=evil.com; Secure; SameSite=None; Path=/;GET /lower(uses lowercasedomain=) returns aCookieError.Impact
Injection of independent cookie attributes (force/drop
Secure/HttpOnly/SameSite, rebindDomain/Path)— the same impact CVE-2026-35536 closed, via the sibling path the patch missed. Conditional on the app using
a capitalized/legacy keyword.
Suggested remediation
Apply the same
[\x00-\x20\x3b\x7f]validation to every entry in the**kwargsloop (after normalizing thekey case), or remove the deprecated kwargs path; add a regression test for capitalized kwargs.
Credit
Reported as part of an incomplete-patch measurement study (responsible disclosure).
Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
tornado: multipart split() creates huge temp list before max_parts check -> memory amplification DoS (httputil.py:34)
GHSA-8423-8fgw-73vq
More information
Details
Description
Summary
parse_multipart_form_data(httputil.py:34) callsdata.split(b"--"+boundary+b"\r\n")before themax_partscheck (:35).A 600KB body with 100k parts creates a 100k-element transient list first,
then rejects transient memory amplification (each split element is a copy).
Pre-auth HTTP DoS.
Root cause
PoC
gist: https://gist.github.com/afldl/649861f25d39b53b7edbe0298e171617
poc.py+output.txt(100k parts from 600KB transient list).Fix
Count separators without materializing the list (e.g.
data.count(b"--"+boundary)first).Credit
Reported by afldl, 2026-07.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Tornado: Urlencoded body parsing omits max_num_fields, so one request can stall the event loop
CVE-2026-82397 / GHSA-mpf4-983q-p7j4
More information
Details
Summary
Tornado parses
application/x-www-form-urlencodedbodies withurllib.parse.parse_qsand does not passmax_num_fields. A body made almost entirely of separators produces tens of millions of fields, and the parse happens on the event loop before the handler runs, so a single request stalls the whole server.Where it is
tornado/escape.py, at HEADe530031405e2154654dedc4c84d5656b557ea310:max_num_fieldsis the parameter CPython added for exactly this, and it is absent.The path to it is entirely server-side and pre-dispatch.
RequestHandler._executeparses the body attornado/web.py:1821, which reachesHTTPServerRequest._parse_bodyattornado/httputil.py:636, and the urlencoded branch ofparse_body_argumentscallsparse_qs_bytesattornado/httputil.py:1030.The size that reaches it is bounded only by the body cap, which defaults to the stream's
max_buffer_sizeof 104857600 attornado/iostream.py:239, applied as the request body default attornado/http1connection.py:136-140. A 100 MB body of separators is around fifty million fields.Impact
Denial of service against the whole process, not one request. Tornado is single-threaded and the parse is synchronous on the event loop, so every other connection waits. No authentication is needed if any route accepts a form post, which is the normal case.
Suggested fix
Pass a bound:
with a conservative default and a way for applications to raise it. CPython raises
ValueErrorwhen the limit is exceeded, which maps cleanly onto a 400.Lowering the default body cap for urlencoded specifically would help too, since 100 MB of form fields is not a shape any real client sends.
Why I do not think this is a duplicate
The published tornado advisories cover out-of-bounds access in the C extension, unbounded accumulation of decompressed chunks in
AsyncHTTPClient, the Authorization header surviving cross-origin redirects, credential leakage on curl handle reuse, and cookie attribute validation. The decompression one is the nearest in spirit and is on the client side; this is the server parsing a request body. The call is unchanged at HEAD.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
tornado: multipart split() creates huge temp list before max_parts check -> memory amplification DoS (httputil.py:34)
CVE-2026-91990 / GHSA-8423-8fgw-73vq
More information
Details
Description
Summary
parse_multipart_form_data(httputil.py:34) callsdata.split(b"--"+boundary+b"\r\n")before themax_partscheck (:35).A 600KB body with 100k parts creates a 100k-element transient list first,
then rejects transient memory amplification (each split element is a copy).
Pre-auth HTTP DoS.
Root cause
PoC
gist: https://gist.github.com/afldl/649861f25d39b53b7edbe0298e171617
poc.py+output.txt(100k parts from 600KB transient list).Fix
Count separators without materializing the list (e.g.
data.count(b"--"+boundary)first).Credit
Reported by afldl, 2026-07.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Tornado: Urlencoded body parsing omits max_num_fields, so one request can stall the event loop
CVE-2026-82397 / GHSA-mpf4-983q-p7j4 / PYSEC-2026-3928
More information
Details
Summary
Tornado parses
application/x-www-form-urlencodedbodies withurllib.parse.parse_qsand does not passmax_num_fields. A body made almost entirely of separators produces tens of millions of fields, and the parse happens on the event loop before the handler runs, so a single request stalls the whole server.Where it is
tornado/escape.py, at HEADe530031405e2154654dedc4c84d5656b557ea310:max_num_fieldsis the parameter CPython added for exactly this, and it is absent.The path to it is entirely server-side and pre-dispatch.
RequestHandler._executeparses the body attornado/web.py:1821, which reachesHTTPServerRequest._parse_bodyattornado/httputil.py:636, and the urlencoded branch ofparse_body_argumentscallsparse_qs_bytesattornado/httputil.py:1030.The size that reaches it is bounded only by the body cap, which defaults to the stream's
max_buffer_sizeof 104857600 attornado/iostream.py:239, applied as the request body default attornado/http1connection.py:136-140. A 100 MB body of separators is around fifty million fields.Impact
Denial of service against the whole process, not one request. Tornado is single-threaded and the parse is synchronous on the event loop, so every other connection waits. No authentication is needed if any route accepts a form post, which is the normal case.
Suggested fix
Pass a bound:
with a conservative default and a way for applications to raise it. CPython raises
ValueErrorwhen the limit is exceeded, which maps cleanly onto a 400.Lowering the default body cap for urlencoded specifically would help too, since 100 MB of form fields is not a shape any real client sends.
Why I do not think this is a duplicate
The published tornado advisories cover out-of-bounds access in the C extension, unbounded accumulation of decompressed chunks in
AsyncHTTPClient, the Authorization header surviving cross-origin redirects, credential leakage on curl handle reuse, and cookie attribute validation. The decompression one is the nearest in spirit and is on the client side; this is the server parsing a request body. The call is unchanged at HEAD.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Tornado: Incomplete fix for CVE-2026-35536: cookie attribute injection re-opened via the legacy case-insensitive
**kwargspath inset_cookieCVE-2026-91991 / GHSA-wwv5-g3v4-889x
More information
Details
Summary
The CVE-2026-35536 fix added a validation loop that rejects
[\x00-\x20\x3b\x7f], but only for thehardcoded lowercase keys
name/domain/path/samesite. The still-live deprecated**kwargspathwrites attacker-supplied attribute values straight into the
Morselwith no validation, and becauseMorsel.__setitem__is case-insensitive, a capitalized kwarg (Domain=,Path=,SameSite=,Max-Age=)routes to the same reserved attribute while bypassing the loop — re-opening
;-delimited attribute injection.The patch's regression test (
SetCookieForbiddenCharHandler) only exercises the four named params, never the**kwargspath, so the gap is not regression-covered.Affected code
tornado/web.py→RequestHandler.set_cookie: the validation loop covers only the lowercase named args;the trailing
if kwargs:loop doesmorsel[k] = vwith no character validation.Steps to reproduce
GET /upper(usesDomain=kwarg) emitsSet-Cookie: c_upper=v; Domain=evil.com; Secure; SameSite=None; Path=/;GET /lower(uses lowercasedomain=) returns aCookieError.Impact
Injection of independent cookie attributes (force/drop
Secure/HttpOnly/SameSite, rebindDomain/Path)— the same impact CVE-2026-35536 closed, via the sibling path the patch missed. Conditional on the app using
a capitalized/legacy keyword.
Suggested remediation
Apply the same
[\x00-\x20\x3b\x7f]validation to every entry in the**kwargsloop (after normalizing thekey case), or remove the deprecated kwargs path; add a regression test for capitalized kwargs.
Credit
Reported as part of an incomplete-patch measurement study (responsible disclosure).
Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Tornado: Urlencoded body parsing omits max_num_fields, so one request can stall the event loop
CVE-2026-82397 / GHSA-mpf4-983q-p7j4 / PYSEC-2026-3928
More information
Details
Summary
Tornado parses
application/x-www-form-urlencodedbodies withurllib.parse.parse_qsand does not passmax_num_fields. A body made almost entirely of separators produces tens of millions of fields, and the parse happens on the event loop before the handler runs, so a single request stalls the whole server.Where it is
tornado/escape.py, at HEADe530031405e2154654dedc4c84d5656b557ea310:max_num_fieldsis the parameter CPython added for exactly this, and it is absent.The path to it is entirely server-side and pre-dispatch.
RequestHandler._executeparses the body attornado/web.py:1821, which reachesHTTPServerRequest._parse_bodyattornado/httputil.py:636, and the urlencoded branch ofparse_body_argumentscallsparse_qs_bytesattornado/httputil.py:1030.The size that reaches it is bounded only by the body cap, which defaults to the stream's
max_buffer_sizeof 104857600 attornado/iostream.py:239, applied as the request body default attornado/http1connection.py:136-140. A 100 MB body of separators is around fifty million fields.Impact
Denial of service against the whole process, not one request. Tornado is single-threaded and the parse is synchronous on the event loop, so every other connection waits. No authentication is needed if any route accepts a form post, which is the normal case.
Suggested fix
Pass a bound:
with a conservative default and a way for applications to raise it. CPython raises
ValueErrorwhen the limit is exceeded, which maps cleanly onto a 400.Lowering the default body cap for urlencoded specifically would help too, since 100 MB of form fields is not a shape any real client sends.
Why I do not think this is a duplicate
The published tornado advisories cover out-of-bounds access in the C extension, unbounded accumulation of decompressed chunks in
AsyncHTTPClient, the Authorization header surviving cross-origin redirects, credential leakage on curl handle reuse, and cookie attribute validation. The decompression one is the nearest in spirit and is on the client side; this is the server parsing a request body. The call is unchanged at HEAD.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
Tornado: Unbounded query-string argument count allows event-loop-stalling DoS
CVE-2026-103261 / GHSA-3hv7-mjh2-fv65
More information
Details
Summary
HTTPServerRequest.__init__intornado/httputil.pyparses the URL query string viaparse_qs_bytes()with no field-count limit — while the sibling POST-body parsing path(
parse_body_arguments) received amax_num_fields=1000cap added earlier in this exactsame release (v6.5.8, commit
8d6363ed), explicitly to bound parsing cost for the identicalunderlying primitive. This leaves the query-string path with the resource-exhaustion exposure
the body-path fix was meant to close.
File:
tornado/httputil.py, line 553 (HTTPServerRequest.__init__)Root Cause
Compare with the POST-body path fixed one commit earlier in the same release:
Both call sites funnel through the same
tornado.escape.parse_qs_bytes(a thin wrapper overurllib.parse.parse_qs), which is exactly whymax_num_fieldswas added tourllib.parse.parse_qslupstream — to let frameworks bound field count. The fix was appliedonly to the body path; the query-string path was missed.
The request line + headers together are capped at
max_header_size(default 65536 bytes), sothis is not literally unbounded, but a single ~64KB request line can carry thousands of short
key=valuepairs — far beyond the 1000-field limit the maintainer judged appropriate for thestructurally identical body case.
Attack Scenario
GETrequest whose query string is packed with thousands of short fields(e.g.
k0=1&k1=1&...&k7799=1, ~61KB), fitting comfortably undermax_header_size. Noauthentication, cookies, or prior state required.
request (which is correctly rejected with 400 once >1000 fields are present).
single-threaded
IOLoop. Several such requests in flight concurrently stall the eventloop, delaying processing of all other connections on that loop — not just the
attacker's own request.
Verification (dynamic, local reproduction against v6.5.8)
Ran the unmodified v6.5.8 source directly (no external dependencies needed) with a minimal
tornado.web.Applicationon127.0.0.1:8888.200 OK; sent as POST body(
application/x-www-form-urlencoded) →400 Bad Request(correctly rejected by theexisting
max_num_fieldsbody-path limit). This confirms the asymmetry directly./?a=1) averaged 1.86ms; the 7800-field query stringaveraged 25.1ms (~13x).
client overhead): with 10 sequential baseline probe requests fired with no load, average
latency was 1.47ms (max 5.9ms). With 5 concurrent 61KB/7800-field requests in flight,
the same baseline probes averaged 13.0ms (max 118.1ms) — an 8.9x average slowdown for
unrelated clients, produced by ~305KB of unauthenticated attacker traffic.
Impact
All Tornado servers/applications are affected — this triggers on every request with a query
string, independent of application/handler logic. An unauthenticated, unprivileged remote
attacker can measurably degrade response times for all other clients sharing the same
IOLoop, using a small amount of bandwidth and no special conditions. This is anavailability/DoS concern; no confidentiality or integrity impact.
Recommended Fix
This reuses the existing
ParseUrlEncodedConfig.max_argumentsdefault (1000) via themodule's
_DEFAULT_PARSE_BODY_CONFIG, matching the POST-body limit and honoring any globaloverride via
set_parse_body_config(). Thetry/exceptis necessary because — unlikeparse_body_arguments, which already wraps its call and convertsValueErrorinto a cleanHTTPInputError/400 — the query-string call site currently has no such handling, so withoutit, a request exceeding the limit would raise an uncaught
ValueErrorinstead of a clean 400.Verified: with the fix applied, requests with ≤1000 query-string fields are unaffected;
requests with >1000 fields are rejected with
400 Bad Request(consistent with the POST-bodybehavior); Tornado's own
httputil_testandweb_testsuites (256 tests) pass unchanged.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Tornado: StaticFileHandler follows symlinks outside static root (path traversal)
CVE-2026-103263 / GHSA-c2m8-h5v5-343r
More information
Details
Summary
StaticFileHandler allows an unauthenticated attacker to read arbitrary files from the server's filesystem by requesting a path that resolves to a symbolic link placed inside the static root directory. Any application that serves user-uploadable content, or whose static directory is populated by a build/deploy pipeline that creates symlinks (e.g. npm link, webpack, Docker volume mounts, CDN sync tools), is affected. An attacker who can trigger the creation of a symlink pointing outside the static root—or exploit one that already exists—can retrieve sensitive files such as /etc/passwd, private keys, configuration files, or application secrets.
Details
The vulnerability is in tornado.web.StaticFileHandler, specifically in the interaction between two methods in tornado/web.py:
os.path.abspath()to resolve the requested path:os.path.abspath()on the root, then performs a string prefix check:os.path.abspath()normalises . and .. segments but does not resolve symbolic links. As a result, a path like/var/www/static/linkpasses thestartswith("/var/www/static/")check regardless of wherelinkactually points.Immediately after,
os.path.exists()andos.path.isfile()do follow symlinks, so the file they ultimately open is the symlink's target. The fix would be to replace os.path.abspath() with os.path.realpath() in both methods, so the resolved real path of the symlink target is validated against the root, not just its string representation inside the static directory.PoC
Complete instructions, including specific configuration details, to reproduce the vulnerability.
Prerequisites: Python 3.x, Tornado installed.
curl http://localhost:8888/static/config.confImpact
Any application using StaticFileHandler is potentially affected if:
An unauthenticated remote attacker can read any file readable by the process user: application secrets, private TLS keys, database credentials, /etc/shadow, SSH keys, or source code (depending on the process's filesystem permissions).
Severity
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
tornado: CurlAsyncHTTPClient enforces no response-size limit — decompression bomb drives unbounded memory accumulation to OOM
CVE-2026-103262 / GHSA-chx6-46f5-w4vp
More information
Details
An unbounded memory accumulation (decompression bomb) in
tornado.curl_httpclient.CurlAsyncHTTPClient— the client-side sibling gap of CVE-2026-49855 — verified end-to-end on the 2026-08-15 master snapshot (6.6.dev1) and present unchanged in the latest release tagv6.5.8and on master (checked 2026-08-17). When a Tornado application configures the curl client (the documented deployment for proxy support / advanced TLS options) andfetch()es an attacker-chosen or attacker-compromised URL with defaultdecompress_response=True, a malicious server replyingContent-Encoding: gzipwith a ~2.8 MB wire bomb drove the client's RSS from 30,884 kB to 1,032,100 kB (~1008 MB) in 3.18 s (~350 MB/s, monotonic, no plateau) until the kernel OOM-killed the process (exit 137, cgroupOOMKilled=true) — with the transfer only 67% complete and no client-side size check ever intervening:curl_httpclient.pycontains zero occurrences ofmax_body_size/MAXFILESIZE. The identical bomb against the defaultSimpleAsyncHTTPClientfails cleanly at ~65 MB, because every size gate CVE-2026-49855 added (compressed CL, chunked total, cumulative decompressed size —http1connection.py:620,676,742) lives in code the curl client never executes. This is a distinct component from the published advisory (which fixed_GzipMessageDelegate/SimpleAsyncHTTPClientonly) and from the other curl-client advisories (credential handle-reuse GHSA-pw6j-qg29-8w7f, header CRLF GHSA-w235-7p84-xx57); the file's full commit history (latest 2026-06-17) shows no response-size work.Details
tornado/curl_httpclient.py(line numbers identical on master6.6.dev1,v6.5.8, and the audited snapshot):libcurl decompresses the response before invoking
WRITEFUNCTION, so the callback receives decompressed bytes, which are appended to an unboundedBytesIOuntil the transfer ends or the process dies. The only ceiling isrequest_timeout(default 20 s) — at zlib's hundreds of MB/s that still permits many GB of accumulation; the PoC raised it to 300 s and the 1 GiB cgroup cap was hit in 3.2 s regardless. There is nopycurl.MAXFILESIZE, nomax_buffer_size/max_body_sizeplumbing (the constructor accepts no body-size option), andstreaming_callbackusers fare no better (the callback variant at:353-356also performs zero accounting).Contrast —
SimpleAsyncHTTPClientpath (tornado/http1connection.py), all absent from the curl path:SimpleAsyncHTTPClient.initialize()defaultsmax_buffer_size = 104857600(100 MiB) withmax_body_sizedefaulting to it (simple_httpclient.py:117-121);CurlAsyncHTTPClient.initialize()has no corresponding parameter at all.Attack chain (attacker = malicious HTTP server; victim = any Tornado app doing
fetch()on attacker-influenced URLs — URL fetchers, webhook processors, link previewers, RSS/probe pollers):AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")(documented for proxy support; proxies are only supported with the curl client).fetch("http://attacker/...")with defaults → request advertisesAccept-Encoding: gzip,deflate.200,Content-Encoding: gzip,Transfer-Encoding: chunked, body = a gzip stream of zeros (4,174,525 wire bytes expanding to 4 GiB, 1029:1, sent in 64 KB chunks).buffer.writewith no size accounting → process RSS climbs linearly until OOM. The response need not complete: the client died with 2,818,048/4,174,525 wire bytes delivered (67%).Variant without compression:
decompress_response=Falseplus an endless streaming body (no Content-Length, no final chunk) feeds the same unaccountedbuffer.write— this client never enforces any cap on any path.PoC
Verified end-to-end 2026-08-15 in a single container (cgroup
--memory 1g --memory-swap 1gso the exhaustion endpoint is safe and fast to observe), two processes over a real TCP socket on127.0.0.1:8081: a raw-socket malicious server (evil_server.py, builds the 4 GiB-of-zeros gzip bomb once, serves it with chunked framing) and the Tornado victim (victim_curl.py, configuresCurlAsyncHTTPClient,fetch(..., request_timeout=300), samples/proc/self/statusVmRSS every 0.2 s from a monitor thread so the curve survives the OOM kill).Build & run the victim
Verified environment: debian:bookworm-slim, Python 3.11.2, python3-pycurl 7.45.2 (libcurl 7.88.1, zlib 1.2.13), tornado master snapshot
6.6.dev1of 2026-08-15 run from the source tree (sys.path), container memory capped at 1 GiB.curl_httpclient.pyverified byte-equivalent (still zero size-limit references) in tagv6.5.8and on master as of 2026-08-17.Reproduction steps
Precondition — the documented curl-client deployment fetching a remote URL. The vulnerability requires the application to use
CurlAsyncHTTPClient(the standard configuration when proxy support or advanced TLS options are needed) with defaultdecompress_response=True, and to fetch a URL whose server the attacker controls or has compromised.victim_curl.pyimplements exactly that (AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")→fetch()), and the server log confirms the ENCODING path engaged — the victim's request arrived withUser-Agent: Mozilla/5.0 (compatible; pycurl)andAccept-Encoding: gzip,deflate.Attack: start
evil_server.py, wait forEVIL_LISTENING, then runvictim_curl.py(the fetch itself is the attack; no further interaction).Expected:
victim_curl_rss.logshows VmRSS 30,884 → 1,032,100 kB over 3.18 s, rising ~350 MB/s with no plateau; the container kills the victim (exit 137, dockerOOMKilled=true); the server logsCLIENT_DIED_MID_TRANSFER wire_sent=2818048/4174525— the accumulation is bounded only by available memory, never by a client-side check, and the 4 GiB payload was never fully delivered.Variant: with
decompress_response=Falseand an unterminated chunked body (server keeps sending forever), the samebuffer.writepath accumulates unbounded plain bytes — no compression needed;request_timeoutonly extends the ceiling.Control:
victim_simple.pyfetches the identical bomb with the defaultSimpleAsyncHTTPClient→ cleanFETCH_FAILED: HTTP 599: Connection closed, RSS peak ~65 MB (24,984 → 66,712 kB), script exit 0 — the CVE-2026-49855 accounting inhttp1connection.pyaborts the transfer, proving the gap is specific to the curl client.PoC source
Full PoC source (victim_curl.py, stdlib only, no dependencies): victim_curl.py (secret gist, unlisted).
The gist carries
evil_server.py(bomb server),victim_curl.py(victim),victim_simple.py(control), andREPRODUCE.md.Captured output (2026-08-15 run, verbatim excerpts)
Honest framing of the endpoint: the OOM kill at ~1008 MB was forced by the test's 1 GiB cgroup cap as the observation instrument; the "unbounded" claim rests on the linear no-plateau RSS curve, death at 67% wire delivery, and the absence of any size accounting in the code path — with more memory the transfer would have continued to the full 4 GiB payload.
Impact
Denial of service (memory exhaustion) of any Tornado application that uses the curl HTTP client and fetches attacker-influenced URLs. A single ~4 MB response kills a 1 GiB process in ~3 s; wire cost scales as
available_memory / 1000. Because the accumulation happens on the shared event loop's client, one malicious response takes down the entire application (all concurrently served users), and a slow endless-body variant drains memory gradually below detection thresholds. The attack requires no privileges, no user interaction, and only that the victim's configured client visits the attacker's origin.Suggested fix
Enforce a byte budget in the write path of
CurlAsyncHTTPClient: wrap theWRITEFUNCTION(both thebuffer.writebranch and thestreaming_callbackbranch) in a counter that aborts the transfer (curl.setopt(pycurl.FAILONERROR)-style cancellation or raising from the callback) once the received total exceedsmax_body_size, plumbed frominitialize()with the same 100 MiB default asSimpleAsyncHTTPClient. Because libcurl decompresses before the write callback, the counter naturally measures decompressed bytes — the same semantics as the CVE-2026-49855 fix. (pycurl.MAXFILESIZEalone is insufficient: it applies to the compressed transfer size.)Affected versions
6.6.dev1, verified 2026-08-15/17).SimpleAsyncHTTPClient/http1connection.pyonly;curl_httpclient.pywas not touched.Credit
Reported by the diff/ambidiff security research effort (afldl).
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Release Notes
tornadoweb/tornado (tornado)
v6.5.9Compare Source
v6.5.8Compare Source
Configuration
📅 Schedule: (in timezone US/Eastern)
🚦 Automerge: Enabled.
♻ Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update again.
This PR was generated by Mend Renovate. View the repository job log.