DEV Community

CVE Reports
CVE Reports

Posted on Originally published at cvereports.com

CVE-2026-116578: CVE-2026-116578: Open Redirect in Tornado RedirectHandler via URL Normalization and Template Substitution

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)
Enter fullscreen mode Exit fullscreen mode

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 ("/", "\\")
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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:

  1. Update project dependency manifests (requirements.txt, pyproject.toml, Pipfile) to require tornado>=6.6.0.
  2. Run pip install --upgrade 'tornado>=6.6.0' across all affected application environments.
  3. 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.
  4. 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


Read the full report for CVE-2026-116578 on our website for more details including interactive diagrams and full exploit analysis.

Top comments (0)