Tornado: Authorization header forwarded across cross-origin redirects in SimpleAsyncHTTPClient
Description
Tornado's SimpleAsyncHTTPClient forwards Authorization headers on cross-origin redirects when follow_redirects=True, leaking credentials.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Tornado's SimpleAsyncHTTPClient forwards Authorization headers on cross-origin redirects when follow_redirects=True, leaking credentials.
Vulnerability
SimpleAsyncHTTPClient in Tornado versions prior to 6.5.6 does not strip Authorization, auth_username, auth_password, or auth_mode when following a 3xx redirect to a different origin (scheme, host, or port). The shallow copy of the original HTTPRequest only removes the Host header, leaving credentials intact and forwarding them to the new origin [1][2]. This behavior applies whenever follow_redirects=True (the default) [1][2].
Exploitation
An attacker who controls a target server that the client communicates with can issue an HTTP redirect (e.g., 302) to an arbitrary origin under their control. If the original request carried credentials (via Authorization header or the convenience auth_username/auth_password parameters), those credentials are automatically included in the follow-up request to the attacker's origin — no additional user interaction is required beyond the initial client request [1][2].
Impact
Successful exploitation results in the disclosure of authentication credentials (e.g., bearer tokens, basic auth credentials) to an unintended third-party origin. This compromises the confidentiality of the credentials, potentially allowing the attacker to replay them against the original service or gain unauthorized access to other resources [1][2].
Mitigation
Upgrade to Tornado 6.5.6 or later, where SimpleAsyncHTTPClient matches libcurl’s behavior and removes Authorization and Cookie headers when the redirect changes the scheme, host, or port [1][2]. No workaround is available for earlier versions other than disabling follow_redirects or manually clearing credentials before redirect.
AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
3- Range: < 6.5.6
- ghsa-coords2 versions
< 6.5.6+ 1 more
- (no CPE)range: < 6.5.6
- (no CPE)range: < 6.5.7-1.1
Patches
1e8fc7edb238fsimple_httpclient: Strip auth headers on cross-origin redirects
2 files changed · +97 −2
tornado/simple_httpclient.py+37 −1 modified@@ -631,6 +631,42 @@ def finish(self) -> None: new_request.url = urllib.parse.urljoin( self.request.url, self.headers["Location"] ) + new_request.headers = self.request.headers.copy() + parsed_orig_url = urllib.parse.urlsplit(original_request.url) + parsed_new_url = urllib.parse.urlsplit(new_request.url) + if ( + parsed_orig_url.scheme != parsed_new_url.scheme + or parsed_orig_url.netloc != parsed_new_url.netloc + ): + # Cross-origin redirect: strip auth headers. + # Note that while there is no formal specification of headers that should be + # stripped here, libcurl strips the Authorization and Cookie headers, so we + # do the same. + # Reference: + # https://github.com/curl/curl/blob/01d8191b25a05e8fa91553a6c0d48acb99907d26/lib/http.c#L1827-L1828 + # + # Note that checking for cross-origin redirects is a crude heuristic. It is both + # too weak (e.g. cookies that have a path attribute may need to be stripped even on + # same-origin redirects) and too strong (e.g. cookies may be kept on cross-host + # redirects within the same domain). However, we cannot know the full details of + # the cookie policy at this layer, so we use the same heuristic as libcurl. + # Applications that need more control over behavior on redirects can set + # follow_redirects=False and handle 3xx responses themselves. + new_request.auth_username = None + new_request.auth_password = None + if "@" in parsed_new_url.netloc: + if parsed_new_url.port is not None: + new_netloc = f"{parsed_new_url.hostname}:{parsed_new_url.port}" + else: + assert parsed_new_url.hostname is not None + new_netloc = parsed_new_url.hostname + parsed_new_url = parsed_new_url._replace(netloc=new_netloc) + new_request.url = urllib.parse.urlunsplit(parsed_new_url) + for h in ["Authorization", "Cookie"]: + try: + del new_request.headers[h] + except KeyError: + pass assert self.request.max_redirects is not None new_request.max_redirects = self.request.max_redirects - 1 del new_request.headers["Host"] @@ -655,7 +691,7 @@ def finish(self) -> None: "Transfer-Encoding", ]: try: - del self.request.headers[h] + del new_request.headers[h] except KeyError: pass new_request.original_request = original_request # type: ignore
tornado/test/httpclient_test.py+60 −1 modified@@ -13,7 +13,7 @@ import unicodedata import unittest -from tornado.escape import utf8, native_str, to_unicode +from tornado.escape import utf8, native_str, to_unicode, json_encode, json_decode from tornado import gen from tornado.httpclient import ( HTTPRequest, @@ -156,6 +156,11 @@ def get(self): self.finish(self.request.headers["Foo"].encode("ISO8859-1")) +class EchoHeadersHandler(RequestHandler): + def get(self): + self.write(json_encode(dict(self.request.headers.get_all()))) + + # These tests end up getting run redundantly: once here with the default # HTTPClient implementation, and then again in each implementation's own # test suite. @@ -181,10 +186,23 @@ def get_app(self): url("/set_header", SetHeaderHandler), url("/invalid_gzip", InvalidGzipHandler), url("/header-encoding", HeaderEncodingHandler), + url("/echo_headers", EchoHeadersHandler), ], gzip=True, ) + def setUp(self): + super().setUp() + + # Add a second port (serving the same app) to the HTTP server, so we can test the effects + # of redirects that span different origins. + sock, port = bind_unused_port() + self.http_server.add_socket(sock) + self.__port2 = port + + def get_url2(self, path: str) -> str: + return f"{self.get_protocol()}://127.0.0.1:{self.__port2}{path}" + def test_patch_receives_payload(self): body = b"some patch data" response = self.fetch("/patch", method="PATCH", body=body) @@ -759,6 +777,47 @@ def test_header_crlf(self): with self.assertRaises(ValueError): self.fetch("/hello", headers={header: "foo"}) + def test_strip_headers_on_redirect(self): + # Ensure that headers that should be stripped on cross-origin redirects + # are stripped, even if the redirect is to a different port on localhost. + test_cases: list[tuple[str, dict, str]] = [ + ("manual auth header", dict(headers={"Authorization": "secret"}), ""), + ("credentials in URL", dict(), "me:secret"), + ("auth parameters", dict(auth_username="me", auth_password="secret"), ""), + ("manual cookie header", dict(headers={"Cookie": "secret"}), ""), + ] + for name, kwargs, url_creds in test_cases: + with self.subTest(name=name, origin="different"): + url = self.get_url( + "/redirect?url=%s&status=302" % self.get_url2("/echo_headers") + ) + if url_creds: + url = url.replace("http://", "http://%s@" % url_creds) + response = self.fetch(**dict(path=url) | kwargs) + response.rethrow() + echoed_headers = json_decode(response.body) + # Confirm that non-auth headers are getting through + self.assertIn("User-Agent", echoed_headers) + # Auth headers are stripped, however they were set. + self.assertNotIn("Authorization", echoed_headers) + self.assertNotIn("Cookie", echoed_headers) + with self.subTest(name=name, origin="same"): + url = self.get_url( + "/redirect?url=%s&status=302" % self.get_url("/echo_headers") + ) + if url_creds: + url = url.replace("http://", "http://%s@" % url_creds) + response = self.fetch(**dict(path=url) | kwargs) + response.rethrow() + echoed_headers = json_decode(response.body) + # Confirm that non-auth headers are getting through + self.assertIn("User-Agent", echoed_headers) + # Auth headers are not stripped when the redirect is same-origin. + # Each of our tests uses one of these headers, but not both. + self.assertTrue( + "Authorization" in echoed_headers or "Cookie" in echoed_headers + ) + class RequestProxyTest(unittest.TestCase): def test_request_set(self):
Vulnerability mechanics
Root cause
"Missing cross-origin check when following HTTP redirects allows credentials to be forwarded to a different origin."
Attack vector
An attacker who controls a redirect target (e.g., via an open redirect on the same server or by luring the client to a malicious endpoint that issues a 302) can receive credentials intended for the original origin. Because `follow_redirects=True` is the default, the client automatically follows the redirect and forwards `Authorization` and `Cookie` headers (or embedded `auth_username`/`auth_password`) to the new host, port, or scheme. This leaks sensitive authentication material to an unauthorized actor [CWE-200]. The attacker only needs to craft a response with a `Location` header pointing to a different origin and have the victim's application make a request to the attacker-controlled URL.
Affected code
The vulnerability is in `tornado/simple_httpclient.py` in the `finish()` method of `SimpleAsyncHTTPClient`. When following a 3xx redirect, the code shallow-copies the original `HTTPRequest`, updates the URL, and decrements `max_redirects`, but prior to the patch it only removed the `Host` header — it did **not** clear `Authorization`, `auth_username`, `auth_password`, or `auth_mode` when the redirect target changed origin. The patch adds a cross-origin check and strips those headers accordingly.
What the fix does
The patch modifies `tornado/simple_httpclient.py` to compare the scheme and netloc of the original URL and the redirect target. When they differ (cross-origin redirect), it sets `auth_username` and `auth_password` to `None`, strips any embedded credentials from the URL, and deletes the `Authorization` and `Cookie` headers from the new request. This matches libcurl's behavior and prevents credential leakage across origins. The patch also fixes a bug where the loop that removes hop-by-hop headers was operating on `self.request.headers` instead of `new_request.headers`.
Preconditions
- configThe client must have `follow_redirects=True` (the default).
- networkThe attacker must be able to serve a response with a 3xx status code and a `Location` header pointing to a different origin (different scheme, host, or port).
- inputThe original request must carry credentials via `Authorization` header, `Cookie` header, or `auth_username`/`auth_password` parameters.
Generated on Jun 15, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.