GHSA-3hv7-mjh2-fv65: Tornado Denial of Service Vulnerability

MODERATE
CVSS 5.3
CWE-770
Published
Advisory Summary

GHSA-3hv7-mjh2-fv65 is a MODERATE (CVSS 5.3) denial of service vulnerability (CWE-770: Allocation of Resources Without Limits or Throttling) affecting Tornado libraries in the PyPI ecosystem. The issue involves unbounded query-string argument count allows event-loop-stalling dos. Remediation is available by upgrading affected packages to version 6.5.9.

GHSA-3hv7-mjh2-fv65 at a Glance

CVE Identifier N/A
GHSA Identifier GHSA-3hv7-mjh2-fv65
Severity MODERATE
CVSS v3.1 Base Score 5.3
CWE Classification CWE-770: Allocation of Resources Without Limits or Throttling
Affected Product Tornado
Package Ecosystem PyPI
Published Date
Last Updated

Security Impact

CVSS: 5.3

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity None (I:N)
Availability Low (A:L)

How to Fix GHSA-3hv7-mjh2-fv65

To remediate this vulnerability, upgrade all affected Tornado dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

tornado fixed: 6.5.9
pip install tornado>=6.5.9
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show tornado

How to Check if You Are Affected

To determine whether your Python environment is affected by GHSA-3hv7-mjh2-fv65, inspect your installed packages using pip:

pip show tornado

Analyze the terminal output to locate any version of Tornado in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 6.5.9), your application is affected.

Note: The pip show tornado command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-770: Allocation of Resources Without Limits or Throttling, where the product allocates a reusable resource or group of resources on behalf of an actor without imposing any intended restrictions on the size or number of resources that can be allocated. In Tornado, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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 ```python # 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: ```python # 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 ```python # 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.

GHSA-3hv7-mjh2-fv65 Affected Tornado Versions

1 component(s)
Fixed Versions:
6.5.9
Product / Package Ecosystem Affected Versions Fixed Version
tornado
PyPI
< 6.5.9
6.5.9

References

Frequently Asked Questions

What is GHSA-3hv7-mjh2-fv65?
GHSA-3hv7-mjh2-fv65 is a security vulnerability in Tornado involving Unbounded query-string argument count allows event-loop-stalling DoS under CWE-770: Allocation of Resources Without Limits or Throttling.
Which Tornado versions are affected?
Affected versions include releases prior to 6.5.9 across affected packages: tornado.
What version fixes GHSA-3hv7-mjh2-fv65?
Upgrading to 6.5.9 (or the latest patched release for your respective branch) resolves the vulnerability.
Is tornado affected?
Yes, tornado is affected in releases prior to 6.5.9. Upgrade your dependency to 6.5.9 or later.
What is the CVSS score of GHSA-3hv7-mjh2-fv65?
GHSA-3hv7-mjh2-fv65 has a CVSS v3.1 base score of 5.3 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L.
What CWE is associated with GHSA-3hv7-mjh2-fv65?
GHSA-3hv7-mjh2-fv65 is classified under CWE-770: Allocation of Resources Without Limits or Throttling (The product allocates a reusable resource or group of resources on behalf of an actor without imposing any intended restrictions on the size or number of resources that can be allocated.).
How can I check PyPI dependencies for GHSA-3hv7-mjh2-fv65?
Run the dependency tree command: 'pip show tornado'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-3hv7-mjh2-fv65",
  "modified" : "2026-09-30T23:49:25Z",
  "published" : "2026-09-30T23:49:25Z",
  "aliases" : [ ],
  "summary" : "Tornado: Unbounded query-string argument count allows event-loop-stalling DoS",
  "details" : "## Summary\n\n`HTTPServerRequest.__init__` in `tornado/httputil.py` parses the URL query string via\n`parse_qs_bytes()` with no field-count limit — while the sibling POST-body parsing path\n(`parse_body_arguments`) received a `max_num_fields=1000` cap added earlier in this exact\nsame release (v6.5.8, commit `8d6363ed`), explicitly to bound parsing cost for the identical\nunderlying primitive. This leaves the query-string path with the resource-exhaustion exposure\nthe body-path fix was meant to close.\n\n**File**: `tornado/httputil.py`, line 553 (`HTTPServerRequest.__init__`)\n\n### Root Cause\n\n```python\n# tornado/httputil.py:553 (before fix)\nself.arguments = parse_qs_bytes(self.query, keep_blank_values=True)\n```\n\nCompare with the POST-body path fixed one commit earlier in the same release:\n\n```python\n# tornado/httputil.py:1038-1041\nuri_arguments = parse_qs_bytes(\n    body,\n    keep_blank_values=True,\n    max_num_fields=config.urlencoded.max_arguments,  # default 1000\n)\n```\n\nBoth call sites funnel through the same `tornado.escape.parse_qs_bytes` (a thin wrapper over\n`urllib.parse.parse_qs`), which is exactly why `max_num_fields` was added to\n`urllib.parse.parse_qsl` upstream — to let frameworks bound field count. The fix was applied\nonly to the body path; the query-string path was missed.\n\nThe request line + headers together are capped at `max_header_size` (default 65536 bytes), so\nthis is not literally unbounded, but a single ~64KB request line can carry thousands of short\n`key=value` pairs — far beyond the 1000-field limit the maintainer judged appropriate for the\nstructurally identical body case.\n\n### Attack Scenario\n\n1. Attacker sends a `GET` request whose query string is packed with thousands of short fields\n   (e.g. `k0=1&k1=1&...&k7799=1`, ~61KB), fitting comfortably under `max_header_size`. No\n   authentication, cookies, or prior state required.\n2. Tornado accepts and parses this with no field-count cap, unlike the equivalent POST-body\n   request (which is correctly rejected with 400 once >1000 fields are present).\n3. Parsing thousands of fields is CPU work performed synchronously inside Tornado's\n   single-threaded `IOLoop`. Several such requests in flight concurrently stall the event\n   loop, delaying processing of *all* other connections on that loop — not just the\n   attacker's own request.\n\n### Verification (dynamic, local reproduction against v6.5.8)\n\nRan the unmodified v6.5.8 source directly (no external dependencies needed) with a minimal\n`tornado.web.Application` on `127.0.0.1:8888`.\n\n- Identical 7800-field/~61KB payload sent as GET query string → `200 OK`; sent as POST body\n  (`application/x-www-form-urlencoded`) → `400 Bad Request` (correctly rejected by the\n  existing `max_num_fields` body-path limit). This confirms the asymmetry directly.\n- Per-request parse cost: baseline (`/?a=1`) averaged 1.86ms; the 7800-field query string\n  averaged 25.1ms (~13x).\n- Event-loop-blocking amplification (raw-socket test, isolating server-side stall from\n  client overhead): with 10 sequential baseline probe requests fired with no load, average\n  latency was 1.47ms (max 5.9ms). With 5 concurrent 61KB/7800-field requests in flight,\n  the same baseline probes averaged 13.0ms (max 118.1ms) — an **8.9x average slowdown** for\n  unrelated clients, produced by ~305KB of unauthenticated attacker traffic.\n\n### Impact\n\nAll Tornado servers/applications are affected — this triggers on every request with a query\nstring, independent of application/handler logic. An unauthenticated, unprivileged remote\nattacker can measurably degrade response times for all other clients sharing the same\n`IOLoop`, using a small amount of bandwidth and no special conditions. This is an\navailability/DoS concern; no confidentiality or integrity impact.\n\n### Recommended Fix\n\n```python\n# tornado/httputil.py — HTTPServerRequest.__init__\nif uri is not None:\n    self.path, sep, self.query = uri.partition(\"?\")\ntry:\n    self.arguments = parse_qs_bytes(\n        self.query,\n        keep_blank_values=True,\n        max_num_fields=_DEFAULT_PARSE_BODY_CONFIG.urlencoded.max_arguments,\n    )\nexcept ValueError as e:\n    raise HTTPInputError(\"Invalid query string: %s\" % e) from e\n```\n\nThis reuses the existing `ParseUrlEncodedConfig.max_arguments` default (1000) via the\nmodule's `_DEFAULT_PARSE_BODY_CONFIG`, matching the POST-body limit and honoring any global\noverride via `set_parse_body_config()`. The `try/except` is necessary because — unlike\n`parse_body_arguments`, which already wraps its call and converts `ValueError` into a clean\n`HTTPInputError`/400 — the query-string call site currently has no such handling, so without\nit, a request exceeding the limit would raise an uncaught `ValueError` instead of a clean 400.\n\nVerified: with the fix applied, requests with ≤1000 query-string fields are unaffected;\nrequests with >1000 fields are rejected with `400 Bad Request` (consistent with the POST-body\nbehavior); Tornado's own `httputil_test` and `web_test` suites (256 tests) pass unchanged.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "tornado"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "6.5.9"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 6.5.8"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/tornadoweb/tornado/security/advisories/GHSA-3hv7-mjh2-fv65"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/tornadoweb/tornado/pull/3719"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/tornadoweb/tornado/commit/03945136ea9746eccf61caf88edae39642e59c93"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/tornadoweb/tornado/commit/8a61dd6005f42733015160f3c23a2bcffe200542"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/tornadoweb/tornado"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/tornadoweb/tornado/releases/tag/v6.5.9"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-770" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-30T23:49:25Z",
    "nvd_published_at" : null
  }
}