Skip to content

fix(deps): update dependency tornado to v6.5.9 [security] - #503

Open
renovate[bot] wants to merge 1 commit into
masterfrom
renovate/pypi-tornado-vulnerability
Open

renovate[bot] wants to merge 1 commit into
masterfrom
renovate/pypi-tornado-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
tornado (source) ==6.5.7 → ==6.5.9 age confidence

Tornado: Incomplete fix for CVE-2026-35536: cookie attribute injection re-opened via the legacy case-insensitive **kwargs path in set_cookie

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 the
hardcoded lowercase keys name/domain/path/samesite. The still-live deprecated **kwargs path
writes attacker-supplied attribute values straight into the Morsel with no validation, and because
Morsel.__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.

self.set_cookie("sid", "abc", Domain="evil.com; Secure; SameSite=None")

#####  -> Set-Cookie: sid=abc; Domain=evil.com; Secure; SameSite=None; Path=/
##### Sanity (the canonical lowercase named arg IS blocked):
self.set_cookie("sid", "abc", domain="evil.com; Secure")   # -> http.cookies.CookieError

The patch's regression test (SetCookieForbiddenCharHandler) only exercises the four named params, never the
**kwargs path, 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 does morsel[k] = v with no character validation.
Steps to reproduce

GET /upper (uses Domain= kwarg) emits Set-Cookie: c_upper=v; Domain=evil.com; Secure; SameSite=None; Path=/; GET /lower (uses lowercase
domain=) returns a CookieError.

Impact

Injection of independent cookie attributes (force/drop Secure/HttpOnly/SameSite, rebind Domain/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 **kwargs loop (after normalizing the
key 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 Score: 2.3 / 10 (Low)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N

References

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) calls
data.split(b"--"+boundary+b"\r\n") before the max_parts check (: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
parts = data[:final_boundary_index].split(b"--" + boundary + b"\r\n")  # :34  huge list first
if len(parts) > config.max_parts:                                       # :35  check after
    raise HTTPInputError("multipart/form-data has too many parts")
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 Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N

References

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-urlencoded bodies with urllib.parse.parse_qs and does not pass max_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 HEAD e530031405e2154654dedc4c84d5656b557ea310:

result = urllib.parse.parse_qs(
    qs, keep_blank_values, strict_parsing, encoding="latin1", errors="strict"
)

max_num_fields is the parameter CPython added for exactly this, and it is absent.

The path to it is entirely server-side and pre-dispatch. RequestHandler._execute parses the body at tornado/web.py:1821, which reaches HTTPServerRequest._parse_body at tornado/httputil.py:636, and the urlencoded branch of parse_body_arguments calls parse_qs_bytes at tornado/httputil.py:1030.

The size that reaches it is bounded only by the body cap, which defaults to the stream's max_buffer_size of 104857600 at tornado/iostream.py:239, applied as the request body default at tornado/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:

result = urllib.parse.parse_qs(
    qs, keep_blank_values, strict_parsing, encoding="latin1", errors="strict",
    max_num_fields=max_num_fields,
)

with a conservative default and a way for applications to raise it. CPython raises ValueError when 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 Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

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) calls
data.split(b"--"+boundary+b"\r\n") before the max_parts check (: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
parts = data[:final_boundary_index].split(b"--" + boundary + b"\r\n")  # :34  huge list first
if len(parts) > config.max_parts:                                       # :35  check after
    raise HTTPInputError("multipart/form-data has too many parts")
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 Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N

References

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-urlencoded bodies with urllib.parse.parse_qs and does not pass max_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 HEAD e530031405e2154654dedc4c84d5656b557ea310:

result = urllib.parse.parse_qs(
    qs, keep_blank_values, strict_parsing, encoding="latin1", errors="strict"
)

max_num_fields is the parameter CPython added for exactly this, and it is absent.

The path to it is entirely server-side and pre-dispatch. RequestHandler._execute parses the body at tornado/web.py:1821, which reaches HTTPServerRequest._parse_body at tornado/httputil.py:636, and the urlencoded branch of parse_body_arguments calls parse_qs_bytes at tornado/httputil.py:1030.

The size that reaches it is bounded only by the body cap, which defaults to the stream's max_buffer_size of 104857600 at tornado/iostream.py:239, applied as the request body default at tornado/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:

result = urllib.parse.parse_qs(
    qs, keep_blank_values, strict_parsing, encoding="latin1", errors="strict",
    max_num_fields=max_num_fields,
)

with a conservative default and a way for applications to raise it. CPython raises ValueError when 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 Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

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 **kwargs path in set_cookie

CVE-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 the
hardcoded lowercase keys name/domain/path/samesite. The still-live deprecated **kwargs path
writes attacker-supplied attribute values straight into the Morsel with no validation, and because
Morsel.__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.

self.set_cookie("sid", "abc", Domain="evil.com; Secure; SameSite=None")

#####  -> Set-Cookie: sid=abc; Domain=evil.com; Secure; SameSite=None; Path=/
##### Sanity (the canonical lowercase named arg IS blocked):
self.set_cookie("sid", "abc", domain="evil.com; Secure")   # -> http.cookies.CookieError

The patch's regression test (SetCookieForbiddenCharHandler) only exercises the four named params, never the
**kwargs path, 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 does morsel[k] = v with no character validation.
Steps to reproduce

GET /upper (uses Domain= kwarg) emits Set-Cookie: c_upper=v; Domain=evil.com; Secure; SameSite=None; Path=/; GET /lower (uses lowercase
domain=) returns a CookieError.

Impact

Injection of independent cookie attributes (force/drop Secure/HttpOnly/SameSite, rebind Domain/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 **kwargs loop (after normalizing the
key 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 Score: 2.3 / 10 (Low)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N

References

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-urlencoded bodies with urllib.parse.parse_qs and does not pass max_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 HEAD e530031405e2154654dedc4c84d5656b557ea310:

result = urllib.parse.parse_qs(
    qs, keep_blank_values, strict_parsing, encoding="latin1", errors="strict"
)

max_num_fields is the parameter CPython added for exactly this, and it is absent.

The path to it is entirely server-side and pre-dispatch. RequestHandler._execute parses the body at tornado/web.py:1821, which reaches HTTPServerRequest._parse_body at tornado/httputil.py:636, and the urlencoded branch of parse_body_arguments calls parse_qs_bytes at tornado/httputil.py:1030.

The size that reaches it is bounded only by the body cap, which defaults to the stream's max_buffer_size of 104857600 at tornado/iostream.py:239, applied as the request body default at tornado/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:

result = urllib.parse.parse_qs(
    qs, keep_blank_values, strict_parsing, encoding="latin1", errors="strict",
    max_num_fields=max_num_fields,
)

with a conservative default and a way for applications to raise it. CPython raises ValueError when 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 Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

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__ in tornado/httputil.py parses the URL query string via
parse_qs_bytes() with no field-count limit — while the sibling POST-body parsing path
(parse_body_arguments) received a max_num_fields=1000 cap added earlier in this exact
same release (v6.5.8, commit 8d6363ed), explicitly to bound parsing cost for the identical
underlying 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
##### tornado/httputil.py:553 (before fix)
self.arguments = parse_qs_bytes(self.query, keep_blank_values=True)

Compare with the POST-body path fixed one commit earlier in the same release:

##### tornado/httputil.py:1038-1041
uri_arguments = parse_qs_bytes(
    body,
    keep_blank_values=True,
    max_num_fields=config.urlencoded.max_arguments,  # default 1000
)

Both call sites funnel through the same tornado.escape.parse_qs_bytes (a thin wrapper over
urllib.parse.parse_qs), which is exactly why max_num_fields was added to
urllib.parse.parse_qsl upstream — to let frameworks bound field count. The fix was applied
only to the body path; the query-string path was missed.

The request line + headers together are capped at max_header_size (default 65536 bytes), so
this is not literally unbounded, but a single ~64KB request line can carry thousands of short
key=value pairs — far beyond the 1000-field limit the maintainer judged appropriate for the
structurally identical body case.

Attack Scenario
  1. Attacker sends a GET request whose query string is packed with thousands of short fields
    (e.g. k0=1&k1=1&...&k7799=1, ~61KB), fitting comfortably under max_header_size. No
    authentication, cookies, or prior state required.
  2. Tornado accepts and parses this with no field-count cap, unlike the equivalent POST-body
    request (which is correctly rejected with 400 once >1000 fields are present).
  3. Parsing thousands of fields is CPU work performed synchronously inside Tornado's
    single-threaded IOLoop. Several such requests in flight concurrently stall the event
    loop, 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.Application on 127.0.0.1:8888.

  • Identical 7800-field/~61KB payload sent as GET query string → 200 OK; sent as POST body
    (application/x-www-form-urlencoded) → 400 Bad Request (correctly rejected by the
    existing max_num_fields body-path limit). This confirms the asymmetry directly.
  • Per-request parse cost: baseline (/?a=1) averaged 1.86ms; the 7800-field query string
    averaged 25.1ms (~13x).
  • Event-loop-blocking amplification (raw-socket test, isolating server-side stall from
    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 an
availability/DoS concern; no confidentiality or integrity impact.

Recommended Fix
##### tornado/httputil.py — HTTPServerRequest.__init__
if uri is not None:
    self.path, sep, self.query = uri.partition("?")
try:
    self.arguments = parse_qs_bytes(
        self.query,
        keep_blank_values=True,
        max_num_fields=_DEFAULT_PARSE_BODY_CONFIG.urlencoded.max_arguments,
    )
except ValueError as e:
    raise HTTPInputError("Invalid query string: %s" % e) from e

This reuses the existing ParseUrlEncodedConfig.max_arguments default (1000) via the
module's _DEFAULT_PARSE_BODY_CONFIG, matching the POST-body limit and honoring any global
override via set_parse_body_config(). The try/except is necessary because — unlike
parse_body_arguments, which already wraps its call and converts ValueError into a clean
HTTPInputError/400 — the query-string call site currently has no such handling, so without
it, a request exceeding the limit would raise an uncaught ValueError instead 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-body
behavior); Tornado's own httputil_test and web_test suites (256 tests) pass unchanged.

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

References

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() normalises . and .. segments but does not resolve symbolic links. As a result, a path like /var/www/static/link passes the startswith("/var/www/static/") check regardless of where link actually points.

Immediately after, os.path.exists() and os.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.

  1. Create the environment
mkdir -p /tmp/static
echo "DB_PASSWORD=s3cr3t" > /tmp/secret.conf   
ln -s /tmp/secret.conf /tmp/static/config.conf 
  1. Minimal vulnerable server (server.py):
import tornado.web, tornado.ioloop

app = tornado.web.Application([
    (r"/static/(.*)", tornado.web.StaticFileHandler, {"path": "/tmp/static"}),
])
app.listen(8888)
tornado.ioloop.IOLoop.current().start()
  1. Exploit:
    curl http://localhost:8888/static/config.conf
Impact

Any application using StaticFileHandler is potentially affected if:

  • the static directory contains symlinks pointing outside it (common with build tooling), or
  • the application allows file uploads into the static directory without stripping symlinks.

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 Score: 8.2 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

References

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 tag v6.5.8 and on master (checked 2026-08-17). When a Tornado application configures the curl client (the documented deployment for proxy support / advanced TLS options) and fetch()es an attacker-chosen or attacker-compromised URL with default decompress_response=True, a malicious server replying Content-Encoding: gzip with 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, cgroup OOMKilled=true) — with the transfer only 67% complete and no client-side size check ever intervening: curl_httpclient.py contains zero occurrences of max_body_size/MAXFILESIZE. The identical bomb against the default SimpleAsyncHTTPClient fails 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/SimpleAsyncHTTPClient only) 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 master 6.6.dev1, v6.5.8, and the audited snapshot):

"buffer": BytesIO(),                                  # :202 — plain BytesIO, no accounting
...
else:
    write_function = buffer.write                     # :359 — every decompressed byte lands here
curl.setopt(pycurl.WRITEFUNCTION, write_function)     # :360
...
if request.decompress_response:                       # default True (HTTPRequest)
    curl.setopt(pycurl.ENCODING, "gzip,deflate")      # :373-374 — libcurl advertises + auto-decodes

libcurl decompresses the response before invoking WRITEFUNCTION, so the callback receives decompressed bytes, which are appended to an unbounded BytesIO until the transfer ends or the process dies. The only ceiling is request_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 no pycurl.MAXFILESIZE, no max_buffer_size/max_body_size plumbing (the constructor accepts no body-size option), and streaming_callback users fare no better (the callback variant at :353-356 also performs zero accounting).

Contrast — SimpleAsyncHTTPClient path (tornado/http1connection.py), all absent from the curl path:

if cast(int, content_length) > self._max_body_size:        # :620  Content-Length gate
if total_size > self._max_body_size:                       # :676  chunked total gate
if self._decompressed_body_size > self._max_body_size:     # :742  CVE-2026-49855 decompressed gate

SimpleAsyncHTTPClient.initialize() defaults max_buffer_size = 104857600 (100 MiB) with max_body_size defaulting 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):

  1. App configures AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient") (documented for proxy support; proxies are only supported with the curl client).
  2. App calls fetch("http://attacker/...") with defaults → request advertises Accept-Encoding: gzip,deflate.
  3. Attacker replies 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).
  4. libcurl auto-decodes at ~350 MB/s into buffer.write with 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=False plus an endless streaming body (no Content-Length, no final chunk) feeds the same unaccounted buffer.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 1g so the exhaustion endpoint is safe and fast to observe), two processes over a real TCP socket on 127.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, configures CurlAsyncHTTPClient, fetch(..., request_timeout=300), samples /proc/self/status VmRSS every 0.2 s from a monitor thread so the curve survives the OOM kill).

Build & run the victim
git clone https://github.com/tornadoweb/tornado
cd tornado
pip install pycurl
python evil_server.py &     # 127.0.0.1:8081; prints BOMB_BUILT wire_bytes=... ratio=... EVIL_LISTENING
python victim_curl.py       # victim: CurlAsyncHTTPClient fetch -> expect linear RSS rise -> OOM
python victim_simple.py     # control: default SimpleAsyncHTTPClient on the same bomb

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.dev1 of 2026-08-15 run from the source tree (sys.path), container memory capped at 1 GiB. curl_httpclient.py verified byte-equivalent (still zero size-limit references) in tag v6.5.8 and on master as of 2026-08-17.

Reproduction steps
  1. 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 default decompress_response=True, and to fetch a URL whose server the attacker controls or has compromised. victim_curl.py implements exactly that (AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient") → fetch()), and the server log confirms the ENCODING path engaged — the victim's request arrived with User-Agent: Mozilla/5.0 (compatible; pycurl) and Accept-Encoding: gzip,deflate.

  2. Attack: start evil_server.py, wait for EVIL_LISTENING, then run victim_curl.py (the fetch itself is the attack; no further interaction).

  3. Expected: victim_curl_rss.log shows 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, docker OOMKilled=true); the server logs CLIENT_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.

  4. Variant: with decompress_response=False and an unterminated chunked body (server keeps sending forever), the same buffer.write path accumulates unbounded plain bytes — no compression needed; request_timeout only extends the ceiling.

  5. Control: victim_simple.py fetches the identical bomb with the default SimpleAsyncHTTPClient → clean FETCH_FAILED: HTTP 599: Connection closed, RSS peak ~65 MB (24,984 → 66,712 kB), script exit 0 — the CVE-2026-49855 accounting in http1connection.py aborts 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), and REPRODUCE.md.

Captured output (2026-08-15 run, verbatim excerpts)
evil_server.log:
BOMB_BUILT wire_bytes=4174525 decompressed_bytes=4294967296 ratio=1029:1
EVIL_LISTENING 127.0.0.1:8081
REQUEST_FROM 127.0.0.1:57544 -> GET /bomb HTTP/1.1
REQUEST_HEADERS:
GET /bomb HTTP/1.1
Host: 127.0.0.1:8081
User-Agent: Mozilla/5.0 (compatible; pycurl)
Accept: */*
Accept-Encoding: gzip,deflate
WIRE_SENT 65536/4174525
WIRE_SENT 2162688/4174525
CLIENT_DIED_MID_TRANSFER wire_sent=2818048/4174525 err=ConnectionResetError(104, 'Connection reset by peer')

victim_curl_rss.log (VmRSS kB, every 0.2 s):
0.00  30884
0.61  231848
1.41  514720
2.22  794480
2.82 1000756
3.18 1032100        <- last sample before kill

driver_f1.log:
timeout 240 python3 /e2e/F1/victim_curl.py ...  606 Killed
VICTIM_CURL_EXIT=137         # docker inspect -> OomKilled: true

victim_simple.log (control, same bomb):
FETCH_FAILED: HTTP 599: Connection closed
SCRIPT_FINISHED_NORMALLY     # exit 0, VmRSS peak 66712 kB

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 the WRITEFUNCTION (both the buffer.write branch and the streaming_callback branch) in a counter that aborts the transfer (curl.setopt(pycurl.FAILONERROR)-style cancellation or raising from the callback) once the received total exceeds max_body_size, plumbed from initialize() with the same 100 MiB default as SimpleAsyncHTTPClient. Because libcurl decompresses before the write callback, the counter naturally measures decompressed bytes — the same semantics as the CVE-2026-49855 fix. (pycurl.MAXFILESIZE alone is insufficient: it applies to the compressed transfer size.)

Affected versions
  • <= 6.5.8 (latest tag; the curl client has never had a response-size limit) and master (6.6.dev1, verified 2026-08-15/17).
  • 6.5.6's CVE-2026-49855 fix covered SimpleAsyncHTTPClient/http1connection.py only; curl_httpclient.py was not touched.
Credit

Reported by the diff/ambidiff security research effort (afldl).

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Release Notes

tornadoweb/tornado (tornado)

v6.5.9

Compare Source

v6.5.8

Compare Source


Configuration

📅 Schedule: (in timezone US/Eastern)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 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.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate
renovate Bot enabled auto-merge (squash) September 1, 2026 21:31
@renovate
renovate Bot force-pushed the renovate/pypi-tornado-vulnerability branch 2 times, most recently from 3f65775 to ed75e7b Compare September 3, 2026 14:13
@renovate
renovate Bot force-pushed the renovate/pypi-tornado-vulnerability branch from ed75e7b to ae9f386 Compare October 1, 2026 16:44
@renovate renovate Bot changed the title fix(deps): update dependency tornado to v6.5.8 [security] fix(deps): update dependency tornado to v6.5.9 [security] Oct 1, 2026
@renovate
renovate Bot force-pushed the renovate/pypi-tornado-vulnerability branch from ae9f386 to d6ee605 Compare October 6, 2026 17:07
@renovate
renovate Bot force-pushed the renovate/pypi-tornado-vulnerability branch from d6ee605 to 0f6d62e Compare October 7, 2026 21:33

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants