DEV Community

CVE-2026-126347: CVE-2026-126347: Open Redirect via Triple-Slash Redirect Paths in Tornado RedirectHandler

Vulnerability ID: CVE-2026-126347 | CVSS Score: 3.1 | Published: 2026-11-13

CVE-2026-126347 (GHSA-5cjw-f85j-cv6r) is an open redirect vulnerability (CWE-601) in Tornado, an asynchronous Python networking library and web framework. The flaw resides in tornado.web.RedirectHandler when dynamic URL redirection templates incorporate unvalidated user input.

Due to a parser differential between RFC 3986 (used by Python's urllib.parse.urlsplit) and the WHATWG URL Standard (used by modern web browsers), inputs containing three or more leading slashes bypass Tornado 6.6.0's validation logic, allowing unauthenticated attackers to redirect users to arbitrary external domains.

TL;DR

Tornado versions prior to 6.6.1 are vulnerable to an open redirect in tornado.web.RedirectHandler when route templates accept dynamic user substitutions. Supplying three or more leading slashes (e.g., ///evil.com) exploits a parsing discrepancy between Python's urllib.parse.urlsplit and modern web browsers, bypassing validation introduced in version 6.6.0 for CVE-2026-116578.

⚠️ Exploit Status: POC

Technical Details

  • Vulnerability Class: CWE-601: URL Redirection to Untrusted Site ('Open Redirect')
  • Affected Component: tornado.web.RedirectHandler (<= 6.6.0)
  • Attack Vector: Network (Unauthenticated HTTP GET with multi-slash path payload)
  • CVSS v3.1 Score: 3.1 Low (CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:L/A:N)
  • EPSS Score: 0.16% (5.59th Percentile)
  • Root Cause: RFC 3986 (urllib.parse.urlsplit) vs. WHATWG URL parser differential on 3+ leading slashes
  • Exploit Status: PoC Available (Unit tests in commit c503241622c0efe7c40110d89834ce8b7fead1e4)
  • Patched Version: Tornado 6.6.1

Affected Systems

Tornado Web Framework (Python package: tornado)

  • tornado: <= 6.6.0 (Fixed in: 6.6.1)

Code Analysis

Commit: c503241

Collapse multiple leading slashes in _scheme_and_netloc and validate RedirectHandler templates using static literal prefix before first substitution.

diff --git a/tornado/web.py b/tornado/web.py
index e6387037..613a0c20 100644
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -72,6 +72,7 @@ import re
 import socket
 import stat
+import string
 import sys
 import threading
 import time
@@ -2848,23 +2849,40 @@ 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}
-        )
+        # from the request. Find out what they are from the part of the template
+        # before the first substitution. (Formatting the whole template with a
+        # placeholder doesn't work: whatever the placeholder is, it can turn the
+        # template into a URL with a different netloc, as "/" does for "/{0}/b".)
+        prefix = ""
+        has_substitution = False
+        for literal, field, _, _ in string.Formatter().parse(self._url):
+            prefix += literal
+            if field is not None:
+                has_substitution = True
+                break
         try:
-            unchanged = _scheme_and_netloc(to_url) == _scheme_and_netloc(expected_url)
+            if self.request.query_arguments:
+                # Query arguments are added before the check because url_concat
+                # can change the rest of the URL.
+                # 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
+                )
+            scheme, netloc = _scheme_and_netloc(prefix)
+            unchanged = _scheme_and_netloc(to_url) == (scheme, netloc)
+            if has_substitution and scheme in ("http", "https") and not netloc:
+                # The host would come entirely from a substitution (or a
+                # browser would find one where urlsplit doesn't, as in
+                # "https:///host").
+                unchanged = False
         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 httputil._FORBIDDEN_HEADER_CHARS_RE.search(to_url):
+            # Let this be a 400 rather than a 500 from set_header.
+            raise HTTPError(400, "Redirect URL contains invalid characters")
         self.redirect(to_url, permanent=self._permanent)
@@ -4251,5 +4269,10 @@ def _scheme_and_netloc(url: str) -> tuple[str, str]:
     Both are empty for a relative URL.
     Raises `ValueError` for some malformed URLs.
     """
-    parts = urllib.parse.urlsplit(_normalize_url_like_browser(url))
+    url = _normalize_url_like_browser(url)
+    # urlsplit follows RFC 3986, in which "///example.com" has an empty netloc,
+    # but browsers resolving it against an http or https URL skip any number of
+    # slashes before the host. Collapse them so urlsplit sees the same host.
+    url = re.sub(r"^//+", "//", url)
+    parts = urllib.parse.urlsplit(url)
     return parts.scheme.lower(), parts.netloc.lower()

Exploit Details

  • GitHub: Official Tornado fix commit containing unit tests and functional reproduction payloads in tornado/test/web_test.py

Mitigation Strategies

  • Upgrade Tornado to version 6.6.1 or newer to ensure multi-slash prefixes are collapsed prior to RFC 3986 URL splitting.
  • Avoid configuring tornado.web.RedirectHandler templates where dynamic substitutions appear at the start of the URL or immediately after a leading slash.
  • Restrict route regular expressions feeding into RedirectHandler to alphanumeric characters ( [a-zA-Z0-9_-]+ ) rather than wildcard capture groups ( .* ).
  • Deploy Web Application Firewall (WAF) rules to inspect and block requests containing multiple consecutive raw or URL-encoded slashes and backslashes targeting redirect endpoints.

Remediation Steps

  • Audit application dependency manifests (requirements.txt, pyproject.toml, Pipfile.lock) to identify tornado versions <= 6.6.0.
  • Run pip install --upgrade 'tornado>=6.6.1' across all affected Python virtual environments and container images.
  • Inspect all tornado.web.RedirectHandler route declarations in the codebase to ensure templates do not use unvalidated host substitutions such as https://{0} or https:{0}.
  • Verify remediation by sending a test request with an encoded double-slash payload (e.g., curl -I 'https://target.example/redirect/%2F%2Fattacker.com') and confirming the server returns HTTP/1.1 400 Bad Request.

References

  • CVE Record (CVE.org): CVE-2026-126347
  • GitHub Security Advisory: GHSA-5cjw-f85j-cv6r
  • Tornado Fix Commit c503241622c0efe7c40110d89834ce8b7fead1e4
  • Tornado v6.6.1 Release Tag
  • CVEListV5 Record: CVE-2026-126347.json
  • NVD Vulnerability Detail: CVE-2026-126347

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

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.