GHSA-3hv7-mjh2-fv65: Medium severity pip/tornado vulnerability

Published Sep 30, 2026
·
Updated

Summary

HTTPServerRequest.init in tornado/httputil.py parses the URL query string via parseqsbytes() with no field-count limit — while the sibling POST-body parsing path (parsebodyarguments) received a maxnumfields=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

python tornado/httputil.py:553 (before fix) self.arguments = parseqsbytes(self.query, keepblankvalues=True)

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

python tornado/httputil.py:1038-1041 uriarguments = parseqsbytes( body, keepblankvalues=True, maxnumfields=config.urlencoded.maxarguments, # default 1000 )

Both call sites funnel through the same tornado.escape.parseqsbytes (a thin wrapper over urllib.parse.parseqs), which is exactly why maxnumfields was added to urllib.parse.parseqsl 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 maxheadersize (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 maxheadersize. 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 maxnumfields 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

python tornado/httputil.py — HTTPServerRequest.init if uri is not None: self.path, sep, self.query = uri.partition("?") try: self.arguments = parseqsbytes( self.query, keepblankvalues=True, maxnumfields=DEFAULTPARSEBODYCONFIG.urlencoded.maxarguments, ) except ValueError as e: raise HTTPInputError("Invalid query string: %s" % e) from e

This reuses the existing ParseUrlEncodedConfig.maxarguments default (1000) via the module's DEFAULTPARSEBODYCONFIG, matching the POST-body limit and honoring any global override via setparsebodyconfig(). The try/except is necessary because — unlike parsebodyarguments, 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 httputiltest and webtest suites (256 tests) pass unchanged.

Affected Software

1 affected componentFixes available
pip/tornado<=6.5.8
6.5.9

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/tornado to a version that resolves this vulnerability.

    Fixed in 6.5.9
  2. Configuration

    Pass max_num_fields=config.urlencoded.max_arguments to parse_qs_bytes() when parsing the query string, and catch ValueError to raise HTTPInputError("Invalid query string: %s" % e), so query strings exceeding the 1000-field default receive 400 Bad Request.

    Tornado HTTPServerRequest query-string parsing max_num_fields = config.urlencoded.max_arguments (default 1000)

Event History

Sep 30, 2026
Advisory Published
via GitHub·11:49 PM
Data Sourced
via GitHub·11:49 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Does the existing URL-encoded POST-body argument limit also protect query-string parsing?

No. The POST-body path passes max_num_fields=config.urlencoded.max_arguments, with a default of 1000, but HTTPServerRequest.__init__ parses query strings without a field-count limit.

2

What traffic can trigger the resource exhaustion condition?

An unauthenticated remote client can send a request containing a query string with an excessive number of fields. The affected parsing occurs during HTTPServerRequest initialization, before application-level handling of the request.

3

How can I determine whether my deployment has this exposure?

Inspect the Tornado httputil.py implementation used by your deployment. It is exposed if HTTPServerRequest.__init__ calls parse_qs_bytes(self.query, keep_blank_values=True) without a max_num_fields argument.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203