SevyDevy

Guide · Backend

Tornado's October 2026 Security Update Explained: Keep the Event Loop Free

21 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • The Tornado security update is Tornado 6.6.0, released on 21 October 2026. Tornado is a Python web server and framework, and Jupyter servers are built on it.
  • It fixes three advisories published on 22 October. Two let a stranger stall the event loop, the single loop that serves every connection in a Tornado process. The worst case, a body of tiny chunks, can stall it for close to a minute.
  • The third is an open redirect: a link on your own domain that can send visitors to any site an attacker picks. It only affects apps that use RedirectHandler with certain URL patterns.
  • Tornado 6.6 needs Python 3.11 or newer. On Python 3.10, a plain pip upgrade quietly stays on the vulnerable 6.5.10, so check both versions after you upgrade.
  • Beginners can stop after the section on what to do today. Engineers then get the mechanisms and tested behaviour changes: folded headers now fail, streaming handlers see chunks joined, and zero timeouts stop waiting.

What goes wrong when one request can freeze a Tornado server?

Every user of that process waits. The Tornado security update, version 6.6.0, fixes two bugs where crafted requests kept Tornado busy and stopped it from serving anyone else.

Tornado runs your app on one event loop. The loop is a single worker that switches between connections very quickly. That design handles thousands of open connections cheaply, but it has one rule: no single task may hold the loop for long.

Picture a common setup. A small team serves a live sales dashboard from two Tornado processes. A few hundred browser tabs keep WebSocket connections open all day; a WebSocket is a connection that stays open so the server can push new numbers. The setup is made up, but the risk is the one the advisories describe.

If one request keeps a process busy parsing, every tab on that process stops updating. The advisory says one body made of tiny chunks could hold a process for close to a minute. No password and no special page are needed, because the slow part runs before your own code.

Which words do you need before reading the advisories?

Eight terms come up again and again. Each one is short to explain.

Terms used in this guide
TermWhat it means here
Event loopThe single loop inside a Tornado process that picks the next ready task and runs it. If one task runs long, all others wait.
HTTP headerA line like Host: example.com sent before the request body. Tornado parses all headers before your handler runs.
Obsolete line foldingAn old HTTP rule that let one header continue on the next line if that line starts with a space or tab. RFC 9112 calls it obs-fold.
Chunked transfer encodingA way to send a request body in pieces, each with its own length, when the total size is not known up front.
Open redirectA page on your site that sends the browser to another site chosen by whoever wrote the link. Phishing emails love these.
CVE and CVSSA CVE is a public ID for one vulnerability. CVSS is a 0 to 10 severity score. Two Tornado bugs scored 5.3, which GitHub labels Moderate.
Reverse proxyA server, such as nginx, that receives every request first and passes it on to Tornado. It can reject or reshape bad requests.
WheelA ready-built package file that pip downloads from PyPI, the public Python package index. Each wheel says which Python versions it supports.

What is the Tornado security update in October 2026?

It is Tornado 6.6.0, published on PyPI on 21 October 2026, plus three GitHub advisories published the next day. Every Tornado version before 6.6 is listed as affected, and there is no 6.5.x patch release.

The three advisories fixed in Tornado 6.6.0 (as of 31 October 2026)
AdvisoryWhat a stranger can doWho is affectedSeverity
CVE-2026-116577, quadratic header parsingSend folded headers that cost far more CPU than their size: tens of milliseconds per request at the default limit, seconds if the limit was raisedAll Tornado HTTP serversModerate, CVSS 5.3
CVE-2026-115927, tiny chunked bodiesSend a body of very many 1-byte chunks that Tornado parses without pausing, for close to a minute near the default size limitServers whose handlers read the body; also the simple HTTP client reading from a malicious serverModerate, CVSS 5.3
CVE-2026-116578, RedirectHandler open redirectCraft a link on your domain that redirects to their own siteApps using RedirectHandler where a captured value can reach the scheme or hostLow

The release notes do not have a separate security section. The fixes sit under the module headings, such as tornado.http1connection and tornado.web, next to normal bug fixes and deprecations.

Why can one request block every connection in Tornado?

Because Tornado does its HTTP parsing on the same loop that serves every other connection. While the loop is inside the parser, it cannot read another socket, send a WebSocket message or run a timer.

Think of a single cashier at a busy shop counter. The cashier is the event loop, each customer is a connection, and checking a bill is parsing. If one customer hands over a bill that takes ten minutes to check, everyone behind waits. Where the picture breaks: a long bill takes time in step with its length, but folded headers grew much faster than their size.

One event loop serves every connection

Notice that the parser and your handlers share one loop. Time spent parsing a bad request is time no other connection gets.

Diagram connections:

  • Browser tabs to Tornado event loop (wait for their turn)
  • One crafted request to Tornado event loop (arrives like any request)
  • Tornado event loop to HTTP parser (parse request)
  • HTTP parser to Your handlers (only after parsing ends)
  • Browser tabs: Hundreds of open WebSocket connections
  • One crafted request: Folded headers or tiny chunks
  • Tornado event loop: Runs one task at a time
  • HTTP parser: Reads headers and body before your code
  • Your handlers: Send dashboard updates

Running more processes helps, because each has its own loop. It does not remove the problem: an attacker can send one slow request per process.

Who is affected by the Tornado 6.6 advisories?

Anyone running a Tornado HTTP server older than 6.6 is affected by the header bug, because header parsing runs before any handler. The chunked-body bug needs a handler that reads the body, which most POST and PUT handlers do.

The chunked-body bug also affects Tornado's built-in HTTP client, which your code uses to fetch other sites. The advisory says SimpleAsyncHTTPClient, the default one, is affected when it reads a response from a malicious server.

Many people run Tornado without knowing it. On PyPI, jupyter-server, notebook and jupyterlab all list tornado 6.2.0 or newer as a dependency, and bokeh does too on normal platforms. Those minimums allow 6.6 but do not require it.

  • A Jupyter server you open to a network is a Tornado server, so it needs the upgrade like any other.
  • A Jupyter server bound to localhost on your own laptop is far less exposed, because strangers cannot reach it.
  • An app behind a reverse proxy that buffers bodies and rejects folded headers keeps both slow inputs away from Tornado, per the advisory workarounds. Tornado itself is still unfixed.
  • The open redirect only matters if your routes use RedirectHandler with a capture at the start of the target or in the host.

What should you do today about the Tornado security update?

Upgrade to Tornado 6.6 or newer and check that the upgrade really happened. Then test for the behaviour changes listed later, in the section on what else changes in Tornado 6.6.

  1. Check your Python version

    Run python --version. Tornado 6.6 needs Python 3.11 or newer. On 3.10 or older, plan the Python upgrade first.

  2. Check your Tornado version

    Run python -c "import tornado; print(tornado.version)". Anything below 6.6 is affected by all three advisories.

  3. Upgrade and pin

    Run pip install --upgrade "tornado>=6.6" and update the pin in your requirements or lock file. Putting the minimum in the pin makes pip fail loudly instead of quietly staying on 6.5.10.

  4. Verify in the running environment

    Run the version check again inside the real container or virtual environment, not just on your laptop.

  5. Check RedirectHandler routes

    Search your code for RedirectHandler. Make sure every target starts with a fixed prefix ending in a slash, before any captured value.

  6. Run your tests

    Pay attention to streaming upload handlers, WebSocket origin checks and code that uses timeout=0 on Tornado queues. If you installed Tornado with conda, upgrade it with conda.

The Python check matters because pip skips any release that needs a newer Python. We asked pip which Tornado wheel it would pick for each Python version on 31 October 2026.

Which Tornado wheel pip picks, by Python version (pip download, 31 October 2026)
Target PythonWheel pip choseFixed?
3.10tornado-6.5.10No
3.11tornado-6.6Yes

How did line folding make Tornado's header parsing slow?

Much more. Each folded line was glued onto the end of the previous header value, so many short continuation lines made the work grow much faster than the input. The advisory calls it quadratic, written O(n squared): ten times more lines can mean about a hundred times more work.

The header size limit caps the damage. At the default limit, the advisory says each malicious request blocks the loop for tens of milliseconds, so it takes a stream of requests to hurt. If max_header_size was raised, one 1 MB request can block for several seconds.

This bug is a close cousin of CVE-2025-67725. That earlier fix covered many repeated headers with the same name, but not one header continued across many folded lines.

What the fix changed

Tornado 6.6 removed line folding completely. A header line that starts with a space or tab is now rejected. We parsed the same harmless folded header on both versions to see the change.

python
from tornado.httputil import HTTPHeaders, HTTPInputError

raw = "X-Note: first part\r\n  second part\r\nHost: localhost\r\n"
try:
    h = HTTPHeaders.parse(raw)
    print("accepted, X-Note =", repr(h["X-Note"]))
except HTTPInputError as e:
    print("rejected:", e)
Output of the folded-header check (run on 31 October 2026, Python 3.13)
Tornado versionResult
6.5.10accepted, X-Note = 'first part second part'
6.6rejected: header line cannot start with whitespace, folding not supported

RFC 9112, the current HTTP/1.1 standard, says senders must not generate folded headers, so mainstream clients should never trip this. A very old client or a hand-written script that relies on folding will now get an error instead of a joined value.

If you cannot upgrade yet, the section on waiting lists the advisory workarounds for this bug and the next one.

How did tiny chunks block the Tornado event loop?

Tornado 6.5 processed every chunk that had already arrived in one go, without pausing for other connections. A body of millions of 1-byte chunks could hold the loop for close to a minute, according to the advisory.

Memory was a second problem. A handler without streaming kept each chunk as a separate Python object, which the advisory says used about 20 times as much memory as the bytes received.

What the fix changed

Tornado 6.6 now pauses to let other tasks run while it reads any body, joins small chunks before handing them over, and stores non-streaming bodies more compactly. Pausing only shares the loop; it does not make the attacker's request free. The request still uses CPU, and max_body_size caps how much.

The source shows how often it pauses. In tornado/http1connection.py, a counter adds the bytes read, plus 1,024 for every chunk header. When it reaches 262,144 (256 KiB), Tornado yields once with asyncio.sleep(0).

Streaming handlers no longer see chunk boundaries

This is the change most likely to surprise working code. A streaming handler is one marked with @stream_request_body. Tornado hands it the body in pieces through a method called data_received, instead of waiting for the whole body.

In 6.5, data_received was called once per HTTP chunk. In 6.6, small chunks that have already arrived are joined first, up to the 64 KiB chunk_size, so the split depends on network timing.

We sent the same request to both versions: a chunked body of 10 chunks, each 5 bytes, to a handler that records every data_received call.

data_received calls for a 10-chunk body of 50 bytes (run on 31 October 2026)
Tornado versionCallsSizes in bytes
6.5.10105, 5, 5, 5, 5, 5, 5, 5, 5, 5
6.6150

If your code treated each call as one message, it will now see several messages glued together. Put your own message framing inside the body, such as newline-separated JSON, instead of relying on HTTP chunks.

How does the RedirectHandler open redirect work?

RedirectHandler builds the target URL by putting parts of the request path into a template. If a captured part lands at the very start of the path, a request can turn the target into a link to another host.

A target that starts with two slashes, such as //example.com/destination, is a protocol-relative URL. The browser keeps the current scheme and goes to example.com, not to your site.

We ran a tiny local app with two rules on both versions. The first rule has the risky shape from the advisory; the second has a fixed prefix.

python
from tornado.web import Application, RedirectHandler

app = Application([
    # risky: the capture can become the start of the target path
    (r"/(.*)/target", RedirectHandler, {"url": "/{0}/destination"}),
    # safe shape: a fixed prefix ending in / comes before the capture
    (r"/old/(.*)", RedirectHandler, {"url": "/new/{0}"}),
])
Redirect results on a local test server (run on 31 October 2026)
Request pathTornado 6.5.10Tornado 6.6
/docs/target301 to /docs/destination301 to /docs/destination
//example.com/target301 to //example.com/destination400 Bad Request
/old/page1301 to /new/page1301 to /new/page1

In 6.6, RedirectHandler formats the template twice: once with the real values, and once with a plain slash in each slot. If the scheme or the host part differ, it returns 400; the host part includes any user name and port. A template can no longer put a captured value there on purpose.

How Tornado 6.6 checks a redirect target

Notice that the check compares only the scheme and host part. The path can still change freely, which is what normal redirects need.

Diagram connections:

  • Request arrives to Fill template
  • Fill template to Fill again with /
  • Fill again with / to Same scheme and host?
  • Same scheme and host? to 301 redirect (Yes)
  • Same scheme and host? to 400 Bad Request (No)
  • Request arrives: Path parts captured by the regex
  • Fill template: Real captured values
  • Fill again with /: Shows what the template alone means
  • Same scheme and host?: Compare the two results
  • 301 redirect: Target stays on the expected host
  • 400 Bad Request: Request tried to change the host

What else changes in Tornado 6.6 that can break your app?

Tornado 6.6 is a feature release, not a small patch, so it also removes old features and changes some defaults. These are the changes most likely to break working code, from the release notes.

The notes also list security hardening without its own advisory. WebSocket message receiving now yields to the loop, unanswered pings no longer grow memory without limit, and set_cookie max_age can no longer inject cookie attributes.

Tornado 6.6 changes worth testing before you deploy
ChangeWhat you might noticeHow we checked
Python 3.9 and 3.10 droppedpip stays on 6.5.10 on old Pythonpip download for 3.10 and 3.11
Line folding removedFolded headers raise an errorRan on 6.5.10 and 6.6
Small chunks joined before data_receivedFewer, larger calls in streaming handlersRan on 6.5.10 and 6.6
RedirectHandler checks scheme and host400 where a redirect used to happenRan on 6.5.10 and 6.6
Queue timeout=0 returns or raises at onceQueue.get(timeout=0) no longer waits foreverRan on 6.5.10 and 6.6
WebSocket check_origin skipped for same-origin requestsA check_origin override no longer sees same-origin requestsRelease notes only
WebSocket versions 7 and 8 removedOnly RFC 6455 version 13 clients connectRelease notes only
static_url outside static_pathHTTPError 403 instead of a URLRelease notes only

Zero timeouts now mean no waiting

Tornado queue and lock timeouts are deadlines, not seconds: an absolute loop time or a timedelta. In older versions, timeout=0 was treated like no timeout at all. In 6.6 it returns or raises straight away.

We ran Queue().get(timeout=0) on an empty queue, with a one-second outer limit.

Queue.get(timeout=0) on an empty queue (run on 31 October 2026)
Tornado versionResult
6.5.10Still waiting after 1.001 s, stopped by our outer limit
6.6TimeoutError after 0.000 s

The release notes list the lock methods, such as Event.wait, under the same change. In our run, Event().wait(timeout=0) already raised TimeoutError at once on 6.5.10, because Event checks for None while the others checked for a true value. Test the exact calls your code makes.

A new cross-origin protection setting

Tornado 6.6 adds a cross_origin_protection application setting that guards against cross-site requests. The notes say it is expected to become the default in Tornado 6.7, so it is worth trying now in a test environment.

What if you cannot upgrade Tornado yet?

Reduce the impact with the workarounds the advisories list, and set a date for the real upgrade. Size limits only shorten each stall. A proxy that rejects folded headers and buffers bodies keeps both attacks away from Tornado, but Tornado itself stays unfixed.

Advisory workarounds, if 6.6 has to wait
BugWorkaround from the advisoryWhat it does not fix
Quadratic header parsingKeep max_header_size at 64 KB or lower; put a proxy in front that rejects or normalises folded linesA smaller limit still allows short stalls
Tiny chunked bodiesUse a proxy that buffers bodies and forwards them with a Content-Length header; lower max_body_size on the serverA lower limit only shortens each stall
RedirectHandler open redirectPut a fixed prefix ending in / before every substitution, or validate the target in your own handlerNothing else, if every route is fixed

The default max_body_size falls back to the stream's buffer limit of 104,857,600 bytes, which is 100 MiB. The advisory says a body near that size could block a process for close to a minute. Set a lower max_body_size on the HTTPServer.

Per-handler limits need care. Header limits exist only on the server, because headers are parsed before routing. A handler can lower its body limit with self.request.connection.set_max_body_size() in prepare(), but only with @stream_request_body; other handlers have already read the body.

How do you check that the Tornado fix is really in place?

Check the installed version in the environment that serves traffic, then confirm one behaviour change. Expect these exact outputs.

bash
python --version
# expect: Python 3.11.x or newer

python -c "import tornado; print(tornado.version)"
# expect: 6.6 (or a later version)

python -c "from tornado.httputil import HTTPHeaders as H; H.parse('A: b\r\n  c\r\n')"
# expect on 6.6: HTTPInputError: header line cannot start with whitespace, folding not supported

On 6.5.10 the last command prints nothing, because the folded header is accepted. Note that tornado.version prints 6.6, not 6.6.0. In scripts, compare tornado.version_info, which is (6, 6, 0, 0), instead of the string.

For containers, run the same commands with docker exec inside each running image. A laptop check only proves your laptop is fixed.

How do you explain the Tornado security update in a system design interview?

Use it as an example of why event-loop servers need limits on every piece of untrusted work, not only on your own handlers.

  1. Name the model

    Tornado serves many connections from one event loop per process, so any long task blocks every connection on that process.

  2. Name the inputs

    HTTP headers and request bodies are parsed before application code, so they are reachable without authentication.

  3. Show the failure

    Folded headers cost quadratic CPU, and millions of tiny chunks were parsed without a pause. One chunked request could stall everyone for close to a minute.

  4. Describe the fix

    Remove the risky feature, yield to the loop after a fixed amount of work, and join small chunks to cut overhead.

  5. Add defence in depth

    Put a buffering reverse proxy in front, set header and body limits on the server, and run several processes.

  6. Close with the redirect lesson

    Never let request data choose the host of a redirect; check the scheme and host after filling the template.

Questions people ask

Is there a Tornado 6.5.x patch for these advisories?
No, not as of 31 October 2026. All three advisories list every version before 6.6 as affected and 6.6.0 as the patched version. The last 6.5 build on PyPI is 6.5.10 from 15 September 2026. If you are stuck on Python 3.10, you need a Python upgrade before you can take the fix.
Does upgrading Jupyter fix the Tornado vulnerability?
Not by itself. Jupyter Server, Notebook and JupyterLab require tornado 6.2.0 or newer, so an older Tornado still satisfies them. Upgrade Tornado directly with pip install --upgrade "tornado>=6.6", then check tornado.version in the same environment that runs Jupyter.
Is a Jupyter notebook on my laptop at risk?
The risk is much lower if it only listens on localhost, because strangers cannot reach it. The bugs need a network request to the server. A notebook server opened to a shared network or the internet is a different story, and it should be upgraded quickly.
Will the line folding removal break my clients?
Almost certainly not, unless you have a very old or hand-written client. RFC 9112 says senders must not generate folded headers, so mainstream clients should never send them. If a client does, Tornado 6.6 now rejects the request. The error mentions whitespace at the start of a header line, so it is easy to find in logs.
Why did my streaming upload handler change behaviour after the upgrade?
Because Tornado 6.6 joins small HTTP chunks that have already arrived before calling data_received. In our test, ten 5-byte chunks sent together became one 50-byte call; sent 50 ms apart, they stayed ten calls. Treat data_received as a stream of bytes and add your own message boundaries, such as newlines, inside the body.
Does running several Tornado processes protect me?
It helps but does not fix the bugs. Each process has its own event loop, so one slow request stalls only one process. An attacker can still send one slow request per process. Combine more processes with the upgrade, a buffering proxy and sensible body limits.
How serious is the RedirectHandler bug?
Tornado rates it Low. It does not leak data or run code; it lets someone build a link on your domain that sends people elsewhere, which helps phishing. It only applies if your RedirectHandler templates put a captured value at the start of the path or in the host.

Sources and further reading

Practise this with Lala

Practise