Nov 14, 2026·11 min read·1 visit
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.
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.
Tornado is an asynchronous networking library and web application framework for Python designed to handle high-concurrency network traffic. Within its core web routing package, the tornado.web.RedirectHandler class provides a declarative mechanism to redirect incoming HTTP GET requests to a specified target URL. Developers can configure RedirectHandler with static destination strings or dynamic format templates that interpolate regex capture groups from the request URI path using standard Python str.format() syntax.
CVE-2026-126347 (tracked also as GHSA-5cjw-f85j-cv6r) is an open redirect vulnerability classified under CWE-601 (URL Redirection to Untrusted Site). When an application defines a RedirectHandler route where user-controlled path parameters are substituted at the beginning of a path or immediately following a leading slash (such as /{0}/destination or {0}), an attacker can supply crafted path segments containing multiple leading slashes or backslashes. Because the server fails to properly identify the target authority within multi-slash sequences, it emits an HTTP 301 Moved Permanently or 302 Found response with a Location header pointing to an attacker-controlled domain.
This vulnerability is the direct result of an incomplete remediation for CVE-2026-116578, which was patched in Tornado 6.6.0. While the 6.6.0 patch attempted to ensure that dynamic template substitutions could not modify the expected URL scheme or network location (netloc), the validation logic relied directly on Python's urllib.parse.urlsplit. Attackers can bypass this check by supplying three or more leading slashes (e.g., ///attacker.com), exploiting a specification divergence between server-side URL parsing and client-side browser navigation.
The technical root cause of CVE-2026-126347 lies in a fundamental parsing discrepancy between RFC 3986 (Uniform Resource Identifier: Generic Syntax), which governs Python's standard urllib.parse.urlsplit function, and the WHATWG URL Living Standard, which dictates how modern web browsers parse and resolve URLs.
Under RFC 3986 Section 3, a network-path reference (protocol-relative URL) begins with exactly two forward slashes (//) followed by the authority component (netloc), which is terminated by the next slash, question mark, or hash character. When urllib.parse.urlsplit processes a string beginning with three or more slashes, such as ///example.com/b, it sees an empty string between the second and third slashes. Consequently, urlsplit concludes that no authority is present, sets netloc to an empty string (""), and assigns the third slash and all subsequent characters (///example.com/b or /example.com/b depending on normalization) to the URL path component.
>>> import urllib.parse
>>> urllib.parse.urlsplit("///example.com/b")
SplitResult(scheme='', netloc='', path='///example.com/b', query='', fragment='')Conversely, web browsers conforming to the WHATWG URL Standard implement state machine rules for relative URL resolution that treat any number of consecutive leading forward slashes (/) and backslashes (\) after a scheme or at the start of a relative reference as equivalent to a two-slash authority delimiter. When a browser receives an HTTP redirect containing Location: ///example.com/b or Location: ////example.com/b and resolves it against an http: or https: base URL, it strips all leading slashes and parses example.com as the target host, navigating the user to https://example.com/b.
In Tornado 6.6.0, the validation function _scheme_and_netloc(url) checked whether the formatted destination URL shared the same (scheme, netloc) tuple as an expected template placeholder formatted with "/". Because ///example.com/b evaluated to ("", "") in urlsplit, it matched the ("", "") tuple of a relative path template, allowing the protocol-relative redirect payload to pass validation unchallenged.
Furthermore, the 6.6.0 remediation contained a functional design flaw in how it constructed expected_url. By substituting "/" for every positional and keyword argument across the entire format string, a legitimate template such as /{0}/b was transformed into ///b. Similarly, a template such as /{0}.html became //.html, which urlsplit parsed as having netloc=".html". This caused valid, non-malicious requests to /{0}.html (where the actual formatted URL had netloc="") to fail the equality check and erroneously return HTTP 400 Bad Request errors.
In Tornado 6.6.0, RedirectHandler.get() validated redirects by comparing the scheme and network location of the user-formatted URL (to_url) against a dummy-formatted version (expected_url), and appended query parameters only after the check was performed:
# Vulnerable implementation in tornado/web.py (Tornado 6.6.0)
def get(self, *args: Any, **kwargs: Any) -> None:
to_url = self._url.format(*args, **kwargs)
# Flaw 1: Formatting with "/" alters netloc of templates like "/{0}.html" -> "//.html"
expected_url = self._url.format(
*("/" for _ in args), **{k: "/" for k in kwargs}
)
try:
# Flaw 2: _scheme_and_netloc("///evil.com") returns ("", ""), bypassing validation
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")
# Flaw 3: Query arguments concatenated after validation can mutate URL structure
if self.request.query_arguments:
to_url = httputil.url_concat(
to_url,
list(httputil.qs_to_qsl(self.request.query_arguments)),
)
self.redirect(to_url, permanent=self._permanent)
def _scheme_and_netloc(url: str) -> tuple[str, str]:
parts = urllib.parse.urlsplit(_normalize_url_like_browser(url))
return parts.scheme.lower(), parts.netloc.lower()Commit c503241622c0efe7c40110d89834ce8b7fead1e4 (released in Tornado 6.6.1) resolves the vulnerability through four structural changes in tornado/web.py. First, _scheme_and_netloc() is updated to collapse any sequence of two or more leading slashes (^//+) down to exactly two slashes (//) after running _normalize_url_like_browser() (which already normalizes backslashes and strips control characters). This forces urllib.parse.urlsplit to parse multi-slash prefixes (///evil.com) identically to modern browsers, extracting evil.com as the netloc.
# Patched _scheme_and_netloc in tornado/web.py (Tornado 6.6.1)
def _scheme_and_netloc(url: str) -> tuple[str, str]:
url = _normalize_url_like_browser(url)
# Collapse 2+ leading slashes so urlsplit sees the same host as browsers
url = re.sub(r"^//+", "//", url)
parts = urllib.parse.urlsplit(url)
return parts.scheme.lower(), parts.netloc.lower()Second, instead of formatting the template with dummy "/" strings, RedirectHandler.get() uses string.Formatter().parse(self._url) to extract only the static literal prefix preceding the very first format field. Because security policy dictates that the scheme and host must be defined statically by the template prior to any dynamic parameter substitution, evaluating _scheme_and_netloc(prefix) provides an immutable baseline without inducing false positives on templates like /{0}.html.
# Patched RedirectHandler.get in tornado/web.py (Tornado 6.6.1)
def get(self, *args: Any, **kwargs: Any) -> None:
to_url = self._url.format(*args, **kwargs)
prefix = ""
has_substitution = False
# Extract only the static literal prefix before the first format field
for literal, field, _, _ in string.Formatter().parse(self._url):
prefix += literal
if field is not None:
has_substitution = True
break
try:
if self.request.query_arguments:
# Apply url_concat before validation to catch urlunsplit mutations
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)
# Explicitly block hostless http/https prefixes where substitution supplies the host
if has_substitution and scheme in ("http", "https") and not netloc:
unchanged = False
except ValueError:
unchanged = False
if not unchanged:
raise HTTPError(400, "Redirect URL cannot change the scheme or netloc")
# Reject forbidden header characters with 400 instead of 500 in set_header
if httputil._FORBIDDEN_HEADER_CHARS_RE.search(to_url):
raise HTTPError(400, "Redirect URL contains invalid characters")
self.redirect(to_url, permanent=self._permanent)Third, the patch adds an explicit check (if has_substitution and scheme in ("http", "https") and not netloc: unchanged = False) to reject templates such as https://{0} or https:{0}. In such templates, prefix has scheme="https" and netloc="". If a user supplied ////evil.com to https:{0}, or if httputil.url_concat re-serialized https:evil.com?x=1 via urlunsplit, a mismatch or host injection would occur. Moving httputil.url_concat prior to _scheme_and_netloc(to_url) and rejecting empty netloc values on http/https templates with substitutions closes these variant vectors completely. Finally, checking httputil._FORBIDDEN_HEADER_CHARS_RE ensures that injected control characters (%0A, %00) return a clean 400 Bad Request rather than an unhandled 500 Internal Server Error inside set_header().
Exploiting CVE-2026-126347 requires no authentication, but depends on the target application exposing a tornado.web.RedirectHandler route where user-controlled path capture groups can control the beginning of the redirect path or immediately follow the initial slash. Because Tornado URL-decodes regex capture groups before passing them to RedirectHandler.get(*args), an attacker can use raw slashes or URL-encoded characters (%2F for /, %5C for \, %09 for horizontal tab) to traverse or match route regular expressions that might otherwise split on unencoded slashes.
Consider a vulnerable Tornado application configured with the following routing table:
import tornado.ioloop
import tornado.web
app = tornado.web.Application([
(r"/redirect/(.*)", tornado.web.RedirectHandler, {"url": "/{0}/destination"}),
(r"/any/(.*)", tornado.web.RedirectHandler, {"url": "{0}"}),
(r"/schemeonly/(.*)", tornado.web.RedirectHandler, {"url": "https:{0}"}),
])To exploit the /redirect/(.*) endpoint, which prepends a single static slash to the captured argument (/{0}/destination), an attacker sends an HTTP GET request containing two or more leading slashes in the capture group, URL-encoded as %2F%2F so the request path is normalized cleanly by upstream proxies:
GET /redirect/%2F%2Fevil.com HTTP/1.1
Host: vulnerable-tornado-app.example
User-Agent: Mozilla/5.0When Tornado processes this request on versions <= 6.6.0:
/redirect/(.*) and URL-decodes %2F%2Fevil.com into //evil.com.RedirectHandler.get() formats /{0}/destination with //evil.com, producing to_url = "///evil.com/destination"._scheme_and_netloc("///evil.com/destination") calls urllib.parse.urlsplit("///evil.com/destination"), which returns scheme="" and netloc="".expected_url (///destination) also yields ("", ""), the validation check passes.HTTP/1.1 301 Moved Permanently and Location: ///evil.com/destination, causing the victim's browser to navigate to https://evil.com/destination.Additional bypass payloads confirmed in the official Tornado regression suite (tornado/test/web_test.py) demonstrate alternative smuggling techniques using backslashes, horizontal tabs, and scheme-only templates:
/any/(.*)): GET /any////evil.com/ HTTP/1.1 -> Location: ////evil.com/GET /redirect/%2F%5Cevil.com HTTP/1.1 or GET /a/%2F%2F%5Cexample.com/b HTTP/1.1GET /redirect/%09//evil.com HTTP/1.1/schemeonly/(.*)): GET /schemeonly////evil.com HTTP/1.1 or GET /schemeonly/evil.com?x=1 HTTP/1.1CVE-2026-126347 is rated Low severity with a CVSS v3.1 base score of 3.1 (CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:L/A:N). The Attack Complexity is scored as High (AC:H) because exploitation is strictly conditional on an application explicitly configuring tornado.web.RedirectHandler with dynamic format strings where user input can influence the authority boundary. Applications that only use RedirectHandler with static target URLs or place substitutions exclusively within query parameters or deep path segments (e.g., /static/prefix/{0}) are not affected.
When a vulnerable route configuration is present, the primary security impact is the facilitation of phishing and credential harvesting attacks (MITRE ATT&CK T1566.002: Spearphishing Link). Because the initial URL inside a phishing link points to a legitimate, trusted domain owned by the target organization, automated email security gateways and end users are more likely to trust the link. Upon clicking the link, the user's browser is transparently redirected to an attacker-controlled external site that can mimic the organization's login portal.
In more complex architectures, open redirects within trusted origin endpoints can also be chained with secondary vulnerabilities. For example, if an OAuth 2.0 or OpenID Connect identity provider validates redirect_uri parameters using permissive prefix matching on the application domain, an attacker can point the redirect_uri to the vulnerable RedirectHandler endpoint and use query parameter forwarding (which RedirectHandler performs automatically when self.request.query_arguments is populated) to leak authorization codes or tokens via the Location header or Referer leakage.
According to FIRST Exploit Prediction Scoring System (EPSS) metrics, CVE-2026-126347 holds an EPSS score of 0.00163 (0.16%), placing it in the 5.59th percentile. The vulnerability is not listed in the CISA Known Exploited Vulnerabilities (KEV) catalog, and there is no evidence of active exploitation in ransomware campaigns.
The definitive remediation for CVE-2026-126347 is to upgrade the tornado package to version 6.6.1 or later. Version 6.6.1 incorporates commit c503241622c0efe7c40110d89834ce8b7fead1e4, which normalizes multi-slash prefixes prior to urlsplit evaluation and validates destination URLs against the static literal prefix of the RedirectHandler template.
pip install --upgrade "tornado>=6.6.1"> [!NOTE]
> Upgrading from Tornado 6.6.0 to 6.6.1 also resolves a functional regression introduced in 6.6.0 where valid relative redirect templates such as /{0}.html erroneously returned HTTP 400 Bad Request.
For environments where an immediate upgrade to Tornado 6.6.1 is not possible, engineering teams can implement the following architectural workarounds and compensating controls:
RedirectHandler: Refactor route definitions so that dynamic substitutions occur only after a static path segment (e.g., use {"url": "/items/{0}/destination"} rather than {"url": "/{0}/destination"} or {"url": "{0}"}). When a static path segment such as /items/ precedes {0}, injecting leading slashes only results in /items////evil.com, which browsers treat as a harmless path on the same origin.Application routing table so that it cannot match forward slashes, backslashes, or whitespace characters. For example, replace (r"/(.*)/target", RedirectHandler, {"url": "/{0}/destination"}) with (r"/([a-zA-Z0-9_-]+)/target", RedirectHandler, {"url": "/{0}/destination"}).RedirectHandler to Strip Leading Slashes: If dynamic root-relative redirects are required on an older Tornado version, implement a custom subclass that strips all leading slashes, backslashes, and control characters from captured arguments before invoking super().get():import re
import tornado.web
class SafeRedirectHandler(tornado.web.RedirectHandler):
def get(self, *args, **kwargs):
clean_args = [re.sub(r"^[/\\\s\x00-\x1f]+", "", a) if isinstance(a, str) else a for a in args]
clean_kwargs = {k: (re.sub(r"^[/\\\s\x00-\x1f]+", "", v) if isinstance(v, str) else v) for k, v in kwargs.items()}
super().get(*clean_args, **clean_kwargs)CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:L/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
tornado tornadoweb | <= 6.6.0 | 6.6.1 |
| Attribute | Detail |
|---|---|
| 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 |
A web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a Redirect. This simplifies phishing attacks.
A Denial of Service (DoS) vulnerability exists in @sveltejs/adapter-node versions 5.5.7 and 6.0.0-next.0 through 6.0.0. When a SvelteKit server route returns a direct response from a fetch() call with a Content-Type of text/event-stream, the Node.js adapter attempts to mutate the response headers to disable proxy buffering. Because headers returned directly by the WHATWG Fetch API carry an immutable guard, this mutation throws an unhandled TypeError, terminating the underlying Node.js process and denying service to all active and subsequent clients.
CVE-2026-128122 (GHSA-5wg4-q3ww-283h) is a build-time CRLF and wildcard injection vulnerability in `@sveltejs/adapter-cloudflare`. During the prerendering phase of a SvelteKit build, the adapter writes crawled redirect paths directly into the Cloudflare `_redirects` and `_routes.json` configuration files without output encoding or character validation. An attacker capable of controlling link paths on prerendered pages can inject newline characters, spaces, or wildcard patterns to forge arbitrary edge redirection rules, enabling asset hijacking and Stored Cross-Site Scripting (XSS).
A vulnerability classified under CWE-116 (Improper Encoding or Escaping of Output) and CWE-625 (Permissive Regular Expression) exists in the @sveltejs/adapter-vercel and @sveltejs/adapter-static packages for SvelteKit. During the build-time prerendering phase, pathnames discovered by the SvelteKit link crawler are inserted into the generated Vercel routing configuration (.vercel/output/config.json) without escaping regular expression metacharacters. Because the Vercel edge routing engine evaluates the 'src' property of route entries as regular expressions, an attacker capable of injecting links into prerendered application content can introduce arbitrary regular expression syntax, hijacking application routing and exposing unintended assets or responses.
CVE-2026-128117 (GHSA-mw6w-2fmv-pvw3) is a denial of service (DoS) vulnerability in SvelteKit (@sveltejs/kit) caused by an uncaught URIError exception during request routing. When an application implements a reroute hook that rewrites a request pathname containing invalid percent-encoding into a valid route, SvelteKit subsequently calls decode_pathname on the original unvalidated url.pathname outside of a try...catch block. In deployments utilizing @sveltejs/adapter-node, this unhandled exception terminates the Node.js runtime process.
CVE-2026-126303 (GHSA-8fx7-8jr8-84rv) is an uncontrolled resource consumption vulnerability in the Natural Language Toolkit (NLTK) prior to version 3.10.4. The flaw is located in the SteppingRecursiveDescentParser.parse() method within the nltk.parse.recursivedescent module. Because the parser executes a top-down recursive descent stepping loop without enforcing an execution time deadline or step limit, processing tokens against a left-recursive, self-embedding, or ambiguous Context-Free Grammar (CFG) causes the parser to enter an infinite loop. This results in complete CPU core exhaustion and worker thread starvation, leading to an application-level denial of service.
File Browser versions prior to 2.63.17 contain an authorization and state-management flaw in the BoltDB storage backend. When an authenticated user deletes a shared directory via an HTTP request with a trailing slash (e.g., DELETE /api/resources/a/), the share cleanup routine queries the database index using the unnormalized path before stripping the trailing slash. Because canonical share paths are stored without trailing slashes (e.g., /a), the database prefix query fails to match the parent directory share record. If a directory with the same name is later recreated, the dormant public share hash automatically reactivates and exposes the new directory contents to unauthenticated users.