new file mode 100644
@@ -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
+
new file mode 100644
@@ -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
+
new file mode 100644
@@ -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
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> --- .../python3-tornado/CVE-2026-116578_p1.patch | 62 ++++++ .../python3-tornado/CVE-2026-116578_p2.patch | 106 ++++++++++ .../python3-tornado/CVE-2026-116578_p3.patch | 192 ++++++++++++++++++ .../python/python3-tornado_6.5.7.bb | 3 + 4 files changed, 363 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p1.patch create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p2.patch create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p3.patch