CVE-2026-116578: Open Redirect in Tornado RedirectHandler via URL Normalization and Template Substitution
Vulnerability ID: CVE-2026-116578
CVSS Score: 2.1
Published: 2026-10-28
Tornado versions prior to 6.6.0 contain an open redirect vulnerability (CWE-601) in tornado.web.RedirectHandler. When route templates incorporate regular expression capture groups at the start of a path, directly after a fixed host without a trailing slash, or at the beginning of a destination template, unvalidated URL-decoded parameters allow attackers to manipulate the redirect scheme or network location. Because modern browsers normalize URLs according to the WHATWG specification—treating backslashes as forward slashes and stripping control characters—attackers can bypass naive prefix checks and redirect victims to arbitrary external domains.
TL;DR
Tornado < 6.6.0 allows open redirects in RedirectHandler when URL templates place regex substitutions at the start of a path or directly after a hostname. Attackers can supply protocol-relative URLs, backslashes, or authority-altering payloads to redirect users to untrusted external domains.
⚠️ Exploit Status: POC
Technical Details
- Vulnerability Type: CWE-601: URL Redirection to Untrusted Site ('Open Redirect')
- Affected Component: tornado.web.RedirectHandler (versions < 6.6.0)
- Attack Vector: Network (Crafted URI path with active user interaction)
- CVSS v4.0 Score: 2.1 Low (CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:A/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N)
- EPSS Score: 0.46% (39.66th percentile)
- Exploit Status: Proof-of-Concept payloads publicly documented in advisory
- CISA KEV Status: Not Listed
- Fixed Version: Tornado 6.6.0
Affected Systems
- tornadoweb/tornado (PyPI package: tornado)
-
tornado: < 6.6.0 (Fixed in:
6.6.0)
Code Analysis
Commit: 940ea72
Initial double-slash check in RedirectHandler.get()
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -2822,6 +2822,10 @@ def get(self, *args: Any, **kwargs: Any) -> None:
to_url,
list(httputil.qs_to_qsl(self.request.query_arguments)), # type: ignore
)
+ if to_url.startswith("//") and not self._url.startswith("//"):
+ raise ValueError(
+ "Redirect URL cannot start with '//' if template does not."
+ )
self.redirect(to_url, permanent=self._permanent)
Commit: ccb6a2e
Handle backslashes, tabs, and newlines in RedirectHandler and StaticFileHandler using _is_protocol_relative
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -2829,7 +2829,7 @@ def get(self, *args: Any, **kwargs: Any) -> None:
to_url,
list(httputil.qs_to_qsl(self.request.query_arguments)), # type: ignore
)
- if to_url.startswith("//") and not self._url.startswith("//"):
+ if _is_protocol_relative(to_url) and not _is_protocol_relative(self._url):
raise ValueError(
"Redirect URL cannot start with '//' if template does not."
)
@@ -3176,7 +3176,7 @@ def validate_absolute_path(self, root: str, absolute_path: str) -> str | None:
# but there is some prefix to the path that was already
# trimmed by the routing
if not self.request.path.endswith("/"):
- if self.request.path.startswith("//"):
+ if _is_protocol_relative(self.request.path):
# A redirect with two initial slashes is a "protocol-relative" URL.
@@ -4091,3 +4091,17 @@ def _create_signature_v2(secret: str | bytes, s: bytes) -> bytes:
+def _is_protocol_relative(url: str) -> bool:
+ """Returns True if a browser would treat ``url`` as a protocol-relative URL."""
+ url = url.lstrip("".join(chr(i) for i in range(0x21)))
+ url = url.replace("\t", "").replace("\n", "").replace("\r", "")
+ return url[:1] in ("/", "\\") and url[1:2] in ("/", "\\")
Commit: cc75ab0
Enforce invariant scheme and netloc baseline against template placeholders and raise HTTPError(400)
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -2815,6 +2815,14 @@ class RedirectHandler(RequestHandler):
.. versionchanged:: 5.0
If any query arguments are present, they will be copied to the
destination URL.
+
+ .. versionchanged:: 6.6
+ Substituted values may no longer change the scheme or netloc (user
+ info, host, and port) of the destination URL. For example, with a
+ ``url`` of ``"/{0}/b"``, a request that would redirect to
+ ``//example.com/b`` (a protocol-relative URL pointing to another host)
+ now fails with a 400 error. As a consequence, substitutions cannot be
+ used in the scheme or netloc part of the ``url``.
"""
def initialize(self, url: str, permanent: bool = True) -> None:
@@ -2823,16 +2831,24 @@ def initialize(self, url: str, permanent: bool = True) -> None:
def get(self, *args: Any, **kwargs: Any) -> None:
to_url = self._url.format(*args, **kwargs)
+ # The scheme and netloc of the redirect must come from the template, not
+ # from the request. Find out what they are by formatting the template
+ # with a placeholder that cannot extend a scheme or netloc.
+ expected_url = self._url.format(
+ *("/" for _ in args), **{k: "/" for k in kwargs}
+ )
+ try:
+ unchanged = _scheme_and_netloc(to_url) == _scheme_and_netloc(expected_url)
+ except ValueError:
+ unchanged = False
+ if not unchanged:
+ raise HTTPError(400, "Redirect URL cannot change the scheme or netloc")
if self.request.query_arguments:
# TODO: figure out typing for the next line.
to_url = httputil.url_concat(
to_url,
list(httputil.qs_to_qsl(self.request.query_arguments)), # type: ignore
)
- if _is_protocol_relative(to_url) and not _is_protocol_relative(self._url):
- raise ValueError(
- "Redirect URL cannot start with '//' if template does not."
- )
self.redirect(to_url, permanent=self._permanent)
@@ -4093,15 +4109,35 @@ def is_absolute(path: str) -> bool:
return any(path.startswith(x) for x in ["/", "http:", "https:"])
+def _normalize_url_like_browser(url: str) -> str:
+ """Approximates how a browser cleans up a URL before parsing it."""
+ url = url.strip("".join(chr(i) for i in range(0x21)))
+ url = url.replace("\t", "").replace("\n", "").replace("\r", "")
+ return url.replace("\\", "/")
+
def _is_protocol_relative(url: str) -> bool:
- url = url.lstrip("".join(chr(i) for i in range(0x21)))
- url = url.replace("\t", "").replace("\n", "").replace("\r", "")
- return url[:1] in ("/", "\\") and url[1:2] in ("/", "\\")
+ return _normalize_url_like_browser(url).startswith("//")
+
+def _scheme_and_netloc(url: str) -> tuple[str, str]:
+ """Returns the scheme and netloc a browser would see in ``url``.
+
+ The netloc includes the user info and port as well as the host.
+ Both are empty for a relative URL. Raises `ValueError` for some malformed URLs.
+ """
+ parts = urllib.parse.urlsplit(_normalize_url_like_browser(url))
+ return parts.scheme.lower(), parts.netloc.lower()
Mitigation Strategies
- Upgrade Tornado to version 6.6.0 or later to enforce invariant scheme and netloc validation in RedirectHandler.
- Audit application routing tables to eliminate RedirectHandler templates that place substitutions at the beginning of a URL or directly adjacent to a hostname without a separating slash.
- Deploy Web Application Firewall (WAF) rules to inspect and block encoded backslashes (%5C), control characters (%09, %0A, %0D), and double slashes in redirect route paths.
Remediation Steps:
- Update project dependency manifests (requirements.txt, pyproject.toml, Pipfile) to require tornado>=6.6.0.
- Run pip install --upgrade 'tornado>=6.6.0' across all affected application environments.
- Review all tornado.web.RedirectHandler usages to confirm no legitimate routes rely on dynamic hostname or scheme substitution, which now returns HTTP 400 in 6.6.0.
- If unable to upgrade immediately, refactor route tuples from 'https://host{0}' to 'https://host/{0}' and implement a custom RedirectHandler subclass that normalizes and validates URL components.
References
- GitHub Security Advisory: GHSA-pw9p-v7r6-gx4p
- Tornado Pull Request #3786
- Fix Commit 1: 940ea725bdd9bc68231d68a105eef17ea6e11233
- Fix Commit 2: ccb6a2e5482f17997efd4a74b8200d5b4babc035
- Fix Commit 3: cc75ab0eb3af459c714895a49f682ad354f50117
- Tornado v6.6.0 Release
- CVE.org Record: CVE-2026-116578
- NVD Detail: CVE-2026-116578
- CVEListV5 JSON Record: CVE-2026-116578
Read the full report for CVE-2026-116578 on our website for more details including interactive diagrams and full exploit analysis.
Top comments (0)