python3-tornado: Fix CVE-2026-116578

This patch applies the upstream fix as referenced in [4],
using the commit shown in [1], [2] and [3].

[1] https://github.com/tornadoweb/tornado/commit/940ea725bdd9bc68231d68a105eef17ea6e11233
[2] https://github.com/tornadoweb/tornado/commit/ccb6a2e5482f17997efd4a74b8200d5b4babc035
[3] https://github.com/tornadoweb/tornado/commit/cc75ab0eb3af459c714895a49f682ad354f50117
[4] https://nvd.nist.gov/vuln/detail/CVE-2026-116578

Signed-off-by: Yogita Urade <yurade@cisco.com>
Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
Yogita Urade authored and Anuj Mittal committed 2026-11-09 08:44:43 +05:30
1 parent bf1ca462d0
commit 96b34e5296
4 files changed
+363

No files matched your search

@@ -0,0 +1,62 @@
From 940ea725bdd9bc68231d68a105eef17ea6e11233 Mon Sep 17 00:00:00 2001
From: Ben Darnell <ben@bendarnell.com>
Date: Tue, 6 Oct 2026 13:06:04 -0400
Subject: [PATCH] web: Block open redirects in RedirectHandler
When the redirect target is formatted from parts of the input, and
one of those parts appears at the start of the new URL, this can
be used to construct protocol-relative URLs which effectively
make an open redirect. Now we refuse to redirect to a
protocol-relative URL unless the double slash was present in the
configured pattern.
CVE: CVE-2026-116578
Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/940ea725bdd9bc68231d68a105eef17ea6e11233]
Signed-off-by: Yogita Urade <yurade@cisco.com>
---
tornado/test/web_test.py | 12 ++++++++++++
tornado/web.py | 4 ++++
2 files changed, 16 insertions(+)
diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py
index 3ecb3ef5..6008fb50 100644
--- a/tornado/test/web_test.py
+++ b/tornado/test/web_test.py
@@ -3565,6 +3565,18 @@ class RedirectHandlerTest(WebTestCase):
self.assertEqual(response.code, 301)
self.assertEqual(response.headers["Location"], "/b/a/c")
+ def test_redirect_double_slash(self):
+ # Note that the redirect pattern we're using reorders the components.
+ response = self.fetch("/a/example.com/b", follow_redirects=False)
+ self.assertEqual(response.code, 301)
+ self.assertEqual(response.headers["Location"], "/example.com/a/b")
+
+ # With a double slash this becomes //example.com/a/b, which is a protocol-relative
+ # redirect to another host, which we disallow because it is an open redirect.
+ with ExpectLog(app_log, "Uncaught exception"):
+ response = self.fetch("/a//example.com/a/b", follow_redirects=False)
+ self.assertEqual(response.code, 500)
+
class AcceptLanguageTest(WebTestCase):
"""Test evaluation of Accept-Language header"""
diff --git a/tornado/web.py b/tornado/web.py
index d3257221..be3b07c0 100644
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -2707,6 +2707,10 @@ class RedirectHandler(RequestHandler):
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)
--
2.35.6
@@ -0,0 +1,106 @@
From ccb6a2e5482f17997efd4a74b8200d5b4babc035 Mon Sep 17 00:00:00 2001
From: Claude <noreply@anthropic.com>
Date: Sat, 10 Oct 2026 16:48:22 +0000
Subject: [PATCH] web: Catch backslash and whitespace variants of
protocol-relative redirects
The open-redirect checks in RedirectHandler and StaticFileHandler only
looked for a literal leading "//". Browsers follow the WHATWG URL spec,
which treats backslashes like slashes and drops tabs and newlines, so
Location headers such as "/\evil.com" or "/<TAB>/evil.com" are also
protocol-relative redirects to another host.
RedirectHandler was exploitable this way because path arguments are
url-decoded before they are substituted into the template, so a link
to e.g. /%5Cevil.com/... produced a raw backslash in the redirect.
StaticFileHandler checks the undecoded request path, which a browser
cannot make contain a raw backslash, but harden it the same way.
CVE: CVE-2026-116578
Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/ccb6a2e5482f17997efd4a74b8200d5b4babc035]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jhn71YM65mTQ8sdDrEMwS8
Signed-off-by: Yogita Urade <yurade@cisco.com>
---
tornado/test/web_test.py | 16 ++++++++++++++++
tornado/web.py | 18 ++++++++++++++++--
2 files changed, 32 insertions(+), 2 deletions(-)
diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py
index 6008fb50..b6b62e96 100644
--- a/tornado/test/web_test.py
+++ b/tornado/test/web_test.py
@@ -1763,6 +1763,13 @@ class StaticDefaultFilenameRootTest(WebTestCase):
)
self.assertEqual(response.code, 403)
+ def test_no_open_redirect_backslash(self):
+ # Browsers treat a backslash like a slash, so a redirect to /\evil.com
+ # is protocol-relative too. This form doesn't need the server-side path.
+ with ExpectLog(gen_log, ".*cannot redirect path with two initial slashes"):
+ response = self.fetch("/\\evil.com/../dir", follow_redirects=False)
+ self.assertEqual(response.code, 403)
+
class StaticFileWithPathTest(WebTestCase):
def get_app_kwargs(self):
@@ -3577,6 +3584,15 @@ class RedirectHandlerTest(WebTestCase):
response = self.fetch("/a//example.com/a/b", follow_redirects=False)
self.assertEqual(response.code, 500)
+ # Browsers treat backslashes like slashes and ignore tabs and newlines,
+ # so these variants are also protocol-relative. Path arguments are
+ # url-decoded, so they can be smuggled in with percent-encoding.
+ for path in ["/a/%5Cexample.com/b", "/a/%09/example.com/b"]:
+ with self.subTest(path=path):
+ with ExpectLog(app_log, "Uncaught exception"):
+ response = self.fetch(path, follow_redirects=False)
+ self.assertEqual(response.code, 500)
+
class AcceptLanguageTest(WebTestCase):
"""Test evaluation of Accept-Language header"""
diff --git a/tornado/web.py b/tornado/web.py
index be3b07c0..62c0f7d5 100644
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -2707,7 +2707,7 @@ class RedirectHandler(RequestHandler):
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."
)
@@ -3034,7 +3034,7 @@ class StaticFileHandler(RequestHandler):
# 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.
# This means the next path segment is treated as a hostname instead
# of a part of the path, making this effectively an open redirect.
@@ -3926,3 +3926,17 @@ def _create_signature_v2(secret: Union[str, bytes], s: bytes) -> bytes:
def is_absolute(path: str) -> bool:
return any(path.startswith(x) for x in ["/", "http:", "https:"])
+
+
+def _is_protocol_relative(url: str) -> bool:
+ """Returns True if a browser would treat ``url`` as a protocol-relative URL.
+
+ A protocol-relative URL (``//example.com/path``) names a host, so redirecting
+ to one that was built from untrusted input is an open redirect. Browsers
+ follow the WHATWG URL spec, which strips leading control characters and
+ spaces, removes tabs and newlines anywhere in the URL, and treats backslashes
+ like forward slashes, so a plain ``startswith("//")`` check is not enough.
+ """
+ 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 ("/", "\\")
--
2.35.6
@@ -0,0 +1,192 @@
From cc75ab0eb3af459c714895a49f682ad354f50117 Mon Sep 17 00:00:00 2001
From: Claude <noreply@anthropic.com>
Date: Sat, 10 Oct 2026 17:56:53 +0000
Subject: [PATCH] web: Don't let RedirectHandler substitutions change the
scheme or netloc
Rather than looking only for protocol-relative URLs, RedirectHandler now
requires the scheme and netloc (user info, host, and port) of the
redirect to be the same as those of the template formatted with
placeholder values. This also covers templates like
"https://example.com{0}", where a substitution could extend the host
(".evil.com") or add user info ("@evil.com"), and templates beginning
with a substitution, which could otherwise be given an absolute URL.
Such requests, and protocol-relative ones, now get a 400 response
instead of a 500 from an uncaught ValueError.
A consequence is that substitutions can no longer be used in the scheme
or netloc part of a template.
CVE: CVE-2026-116578
Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/cc75ab0eb3af459c714895a49f682ad354f50117]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jhn71YM65mTQ8sdDrEMwS8
Signed-off-by: Yogita Urade <yurade@cisco.com>
---
tornado/test/web_test.py | 42 ++++++++++++++++++++++++++---
tornado/web.py | 58 ++++++++++++++++++++++++++++++++--------
2 files changed, 85 insertions(+), 15 deletions(-)
diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py
index b6b62e96..1438b6f3 100644
--- a/tornado/test/web_test.py
+++ b/tornado/test/web_test.py
@@ -3549,6 +3549,8 @@ class RedirectHandlerTest(WebTestCase):
return [
("/src", WebRedirectHandler, {"url": "/dst"}),
("/src2", WebRedirectHandler, {"url": "/dst2?foo=bar"}),
+ (r"/any/(.*)", WebRedirectHandler, {"url": "{0}"}),
+ (r"/moved(.*)", WebRedirectHandler, {"url": "https://example.com{0}"}),
(r"/(.*?)/(.*?)/(.*)", WebRedirectHandler, {"url": "/{1}/{0}/{2}"}),
]
@@ -3580,18 +3582,50 @@ class RedirectHandlerTest(WebTestCase):
# With a double slash this becomes //example.com/a/b, which is a protocol-relative
# redirect to another host, which we disallow because it is an open redirect.
- with ExpectLog(app_log, "Uncaught exception"):
+ with ExpectLog(gen_log, ".*Redirect URL cannot change the scheme or netloc"):
response = self.fetch("/a//example.com/a/b", follow_redirects=False)
- self.assertEqual(response.code, 500)
+ self.assertEqual(response.code, 400)
# Browsers treat backslashes like slashes and ignore tabs and newlines,
# so these variants are also protocol-relative. Path arguments are
# url-decoded, so they can be smuggled in with percent-encoding.
for path in ["/a/%5Cexample.com/b", "/a/%09/example.com/b"]:
with self.subTest(path=path):
- with ExpectLog(app_log, "Uncaught exception"):
+ with ExpectLog(
+ gen_log, ".*Redirect URL cannot change the scheme or netloc"
+ ):
+ response = self.fetch(path, follow_redirects=False)
+ self.assertEqual(response.code, 400)
+
+ def test_redirect_cannot_change_host(self):
+ # Substitutions directly after the host can extend it or add userinfo.
+ response = self.fetch("/moved/x", follow_redirects=False)
+ self.assertEqual(response.code, 301)
+ self.assertEqual(response.headers["Location"], "https://example.com/x")
+ for path in ["/moved.evil.com/x", "/moved@evil.com/x", "/moved:8080/x"]:
+ with self.subTest(path=path):
+ with ExpectLog(
+ gen_log, ".*Redirect URL cannot change the scheme or netloc"
+ ):
+ response = self.fetch(path, follow_redirects=False)
+ self.assertEqual(response.code, 400)
+
+ def test_template_starting_with_substitution(self):
+ # A relative URL is fine, but the client cannot supply an absolute one.
+ response = self.fetch("/any/foo/bar", follow_redirects=False)
+ self.assertEqual(response.code, 301)
+ self.assertEqual(response.headers["Location"], "foo/bar")
+ for path in [
+ "/any/https://evil.com/",
+ "/any/%5C%5Cevil.com/",
+ "/any/javascript:alert(1)",
+ ]:
+ with self.subTest(path=path):
+ with ExpectLog(
+ gen_log, ".*Redirect URL cannot change the scheme or netloc"
+ ):
response = self.fetch(path, follow_redirects=False)
- self.assertEqual(response.code, 500)
+ self.assertEqual(response.code, 400)
class AcceptLanguageTest(WebTestCase):
diff --git a/tornado/web.py b/tornado/web.py
index 62c0f7d5..0b67216a 100644
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -2693,6 +2693,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:
@@ -2701,16 +2709,24 @@ class RedirectHandler(RequestHandler):
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)
@@ -3928,15 +3944,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.
+
+ Browsers follow the WHATWG URL spec, which strips leading and trailing
+ control characters and spaces, removes tabs and newlines anywhere in the
+ URL, and (for http and https) treats backslashes like forward slashes. Checks
+ on redirect URLs built from untrusted input must account for this.
+ """
+ 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:
"""Returns True if a browser would treat ``url`` as a protocol-relative URL.
A protocol-relative URL (``//example.com/path``) names a host, so redirecting
- to one that was built from untrusted input is an open redirect. Browsers
- follow the WHATWG URL spec, which strips leading control characters and
- spaces, removes tabs and newlines anywhere in the URL, and treats backslashes
- like forward slashes, so a plain ``startswith("//")`` check is not enough.
+ to one that was built from untrusted input is an open redirect.
"""
- 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()
--
2.35.6
@@ -19,6 +19,9 @@ SRC_URI += "file://CVE-2026-91990.patch \
file://CVE-2026-116577_p1.patch \
file://CVE-2026-116577_p2.patch \
file://CVE-2026-116577_p3.patch \
file://CVE-2026-116578_p1.patch \
file://CVE-2026-116578_p2.patch \
file://CVE-2026-116578_p3.patch \
"
inherit pypi python_setuptools_build_meta