mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-11-14 16:07:25 +00:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user