Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐍
🐍 PyPI
Not in CISA KEV
MEDIUM severity

Tornado: Unbounded query-string argument count allows event-loop-stalling DoSGHSA-3hv7-mjh2-fv65

MEDIUMFix: tornadoweb/tornado#3719

GHSA-3hv7-mjh2-fv65 is a medium-severity (CVSS 5.3) CWE-770 vulnerability in tornado. A fix is available for tornado — see the affected versions and patch details below.

Also known asCVE-2026-103261
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Oct 22, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

No confirmed exploitation observed yet

  • CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.
  • CISA’s own triage has not observed active exploitation or public proof-of-concept code for this CVE as of its last assessment.

Exploitation and automatability from CISA’s SSVC triage for GHSA-3hv7-mjh2-fv65.

EPSS Exploitation Probability

via FIRST.org ↗
0.5%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs39th percentile — riskier than 39% of all scored CVEsHighest risk

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

GHSA-3hv7-mjh2-fv65 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.

Where this sits among everything scored

Of 394,803 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.

Real-World Exposure

1 pkg affected
🐍tornado

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.

Description

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.

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐍PyPItornadoall versions6.5.9pip install --upgrade 'tornado==6.5.9'

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for tornado, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update tornado to 6.5.9 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-3hv7-mjh2-fv65 is resolved across your whole dependency graph.

  3. Workarounds

    Cap what an attacker can consume: apply request size, rate and timeout limits in front of the affected component, and run it with memory and CPU limits so exhaustion degrades one worker rather than the whole service.

Fixing This On Your OS

If you run this on a Linux distribution, patch through your package manager against the distro's own security advisory below — it tracks the exact backported fix for your release, which can ship on a different timeline (and sometimes a different severity) than the upstream project.

Red HatModerate

A denial-of-service flaw of moderate impact was found in Tornado, a Python web framework and asynchronous networking library used by several Red Hat offerings, including Red Hat Enterprise Linux (pcs), Red Hat OpenShift Container Platform, and Fedora/EPEL community components. When processing incoming HTTP requests,…

Workaround published by Red Hat
Mitigation for this issue is either not available or the currently available options do not meet the Red Hat Product Security criteria comprising ease of use and deployment, applicability to widespread installation base, or stability.
Source: Red Hat security advisory for GHSA-3hv7-mjh2-fv65 (CC BY 4.0)

Frequently Asked Questions

## 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 ```pytho
O3 Security · Impact-Aware SCA

Is GHSA-3hv7-mjh2-fv65 in your dependencies?

Find it across PyPI, including transitive dependencies.

Tornado: Unbounded query-string argument count allows…