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.
| Term | What it means here |
|---|---|
| Event loop | The single loop inside a Tornado process that picks the next ready task and runs it. If one task runs long, all others wait. |
| HTTP header | A line like Host: example.com sent before the request body. Tornado parses all headers before your handler runs. |
| Obsolete line folding | An 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 encoding | A way to send a request body in pieces, each with its own length, when the total size is not known up front. |
| Open redirect | A page on your site that sends the browser to another site chosen by whoever wrote the link. Phishing emails love these. |
| CVE and CVSS | A 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 proxy | A server, such as nginx, that receives every request first and passes it on to Tornado. It can reject or reshape bad requests. |
| Wheel | A 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.
| Advisory | What a stranger can do | Who is affected | Severity |
|---|---|---|---|
| CVE-2026-116577, quadratic header parsing | Send folded headers that cost far more CPU than their size: tens of milliseconds per request at the default limit, seconds if the limit was raised | All Tornado HTTP servers | Moderate, CVSS 5.3 |
| CVE-2026-115927, tiny chunked bodies | Send a body of very many 1-byte chunks that Tornado parses without pausing, for close to a minute near the default size limit | Servers whose handlers read the body; also the simple HTTP client reading from a malicious server | Moderate, CVSS 5.3 |
| CVE-2026-116578, RedirectHandler open redirect | Craft a link on your domain that redirects to their own site | Apps using RedirectHandler where a captured value can reach the scheme or host | Low |
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
Drag to exploreNotice 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.
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.
Check your Tornado version
Run python -c "import tornado; print(tornado.version)". Anything below 6.6 is affected by all three advisories.
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.
Verify in the running environment
Run the version check again inside the real container or virtual environment, not just on your laptop.
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.
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.
| Target Python | Wheel pip chose | Fixed? |
|---|---|---|
| 3.10 | tornado-6.5.10 | No |
| 3.11 | tornado-6.6 | Yes |
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.
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)| Tornado version | Result |
|---|---|
| 6.5.10 | accepted, X-Note = 'first part second part' |
| 6.6 | rejected: 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.
| Tornado version | Calls | Sizes in bytes |
|---|---|---|
| 6.5.10 | 10 | 5, 5, 5, 5, 5, 5, 5, 5, 5, 5 |
| 6.6 | 1 | 50 |
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.
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}"}),
])| Request path | Tornado 6.5.10 | Tornado 6.6 |
|---|---|---|
| /docs/target | 301 to /docs/destination | 301 to /docs/destination |
| //example.com/target | 301 to //example.com/destination | 400 Bad Request |
| /old/page1 | 301 to /new/page1 | 301 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
Drag to exploreNotice 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.
| Change | What you might notice | How we checked |
|---|---|---|
| Python 3.9 and 3.10 dropped | pip stays on 6.5.10 on old Python | pip download for 3.10 and 3.11 |
| Line folding removed | Folded headers raise an error | Ran on 6.5.10 and 6.6 |
| Small chunks joined before data_received | Fewer, larger calls in streaming handlers | Ran on 6.5.10 and 6.6 |
| RedirectHandler checks scheme and host | 400 where a redirect used to happen | Ran on 6.5.10 and 6.6 |
| Queue timeout=0 returns or raises at once | Queue.get(timeout=0) no longer waits forever | Ran on 6.5.10 and 6.6 |
| WebSocket check_origin skipped for same-origin requests | A check_origin override no longer sees same-origin requests | Release notes only |
| WebSocket versions 7 and 8 removed | Only RFC 6455 version 13 clients connect | Release notes only |
| static_url outside static_path | HTTPError 403 instead of a URL | Release 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.
| Tornado version | Result |
|---|---|
| 6.5.10 | Still waiting after 1.001 s, stopped by our outer limit |
| 6.6 | TimeoutError 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.
| Bug | Workaround from the advisory | What it does not fix |
|---|---|---|
| Quadratic header parsing | Keep max_header_size at 64 KB or lower; put a proxy in front that rejects or normalises folded lines | A smaller limit still allows short stalls |
| Tiny chunked bodies | Use a proxy that buffers bodies and forwards them with a Content-Length header; lower max_body_size on the server | A lower limit only shortens each stall |
| RedirectHandler open redirect | Put a fixed prefix ending in / before every substitution, or validate the target in your own handler | Nothing 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.
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 supportedOn 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.
Name the model
Tornado serves many connections from one event loop per process, so any long task blocks every connection on that process.
Name the inputs
HTTP headers and request bodies are parsed before application code, so they are reachable without authentication.
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.
Describe the fix
Remove the risky feature, yield to the loop after a fixed amount of work, and join small chunks to cut overhead.
Add defence in depth
Put a buffering reverse proxy in front, set header and body limits on the server, and run several processes.
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
- Tornado 6.6.0 release notes (21 October 2026)
- GHSA-pcg2-g6cj-q92c: quadratic CPU usage when parsing headers with line folding (CVE-2026-116577)
- GHSA-r4j5-9xpp-3985: event loop blocking from chunked bodies with many small chunks (CVE-2026-115927)
- GHSA-pw9p-v7r6-gx4p: open redirect in RedirectHandler (CVE-2026-116578)
- tornado 6.6 on PyPI (files and Python requirement)
- RFC 9112 section 5.2: obsolete line folding