Yogita Urade authored and Anuj Mittal committed 2026-11-09 08:44:42 +05:30
1 parent 2248ece66b
commit bf1ca462d0
4 files changed
+398

No files matched your search

@@ -0,0 +1,195 @@
From 1b7a1dc9f340f52de929852e8125aa12f25297c9 Mon Sep 17 00:00:00 2001
From: Ben Darnell <ben@bendarnell.com>
Date: Tue, 6 Oct 2026 09:59:02 -0400
Subject: [PATCH] httputil: Remove support for "obsolete line folding"
This optional part of the HTTP specification is no longer used
in practice, and continuing to support it adds complexity and risk
of DoS via quadratic performance on malicious data.
CVE: CVE-2026-116577
Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/1b7a1dc9f340f52de929852e8125aa12f25297c9]
Backport Changes:
- Kept unittest.TestCase for HTTPHeadersTest because 6.5.7's httputil_test.py
does not import tornado.test.util.TestCase.
- Updated test_multi_line_headers to preserve folded-header acceptance for Curl
and expect a 599 response from simple_httpclient.
- The httputil_test.py hunk removes two extra target-version lines because
6.5.7 formats the removed multiline .replace() expression across three lines.
Signed-off-by: Yogita Urade <yurade@cisco.com>
---
tornado/httputil.py | 34 +++++++++---------------
tornado/test/httpclient_test.py | 17 +++++++++++--
tornado/test/httputil_test.py | 47 +++------------------------------
3 files changed, 29 insertions(+), 69 deletions(-)
diff --git a/tornado/httputil.py b/tornado/httputil.py
index 6a5db233..45e1883d 100644
--- a/tornado/httputil.py
+++ b/tornado/httputil.py
@@ -195,7 +195,6 @@ class HTTPHeaders(StrMutableMapping):
# on demand (and cleared whenever the list is modified).
self._as_list: dict[str, list[str]] = {}
self._combined_cache: dict[str, str] = {}
- self._last_key = None # type: Optional[str]
if len(args) == 1 and len(kwargs) == 0 and isinstance(args[0], HTTPHeaders):
# Copy constructor
for k, v in args[0].get_all():
@@ -219,7 +218,6 @@ class HTTPHeaders(StrMutableMapping):
if _FORBIDDEN_HEADER_CHARS_RE.search(value):
raise HTTPInputError("Invalid header value %r" % value)
norm_name = _normalize_header(name)
- self._last_key = norm_name
if norm_name in self:
self._combined_cache.pop(norm_name, None)
self._as_list[norm_name].append(value)
@@ -260,6 +258,11 @@ class HTTPHeaders(StrMutableMapping):
In Tornado 7.0, certain deprecated features of HTTP will become errors.
Specifically, line folding and the use of LF (with CR) as a line separator
will be removed.
+
+ .. versionchanged:: 6.6
+ Support for line folding was removed due to security/DoS concerns. This removal
+ was ahead of the original removal schedule announced for Tornado 7.0.
+ Support for LF without CR remains until Tornado 7.0.
"""
if m := re.search(r"\r?\n$", line):
# RFC 9112 section 2.2: a recipient MAY recognize a single LF as a line
@@ -270,27 +273,14 @@ class HTTPHeaders(StrMutableMapping):
# Empty line, or the final CRLF of a header block.
return
if line[0] in HTTP_WHITESPACE:
- # continuation of a multi-line header
- # TODO(7.0): Remove support for line folding.
- if self._last_key is None:
- raise HTTPInputError("first header line cannot start with whitespace")
- new_part = " " + line.strip(HTTP_WHITESPACE)
- if _chars_are_bytes:
- if not _ABNF.field_value.fullmatch(new_part[1:]):
- raise HTTPInputError("Invalid header continuation %r" % new_part)
- else:
- if _FORBIDDEN_HEADER_CHARS_RE.search(new_part):
- raise HTTPInputError("Invalid header value %r" % new_part)
- self._as_list[self._last_key][-1] += new_part
- self._combined_cache.pop(self._last_key, None)
- else:
- try:
- name, value = line.split(":", 1)
- except ValueError:
- raise HTTPInputError("no colon in header line")
- self.add(
- name, value.strip(HTTP_WHITESPACE), _chars_are_bytes=_chars_are_bytes
+ raise HTTPInputError(
+ "header line cannot start with whitespace, folding not supported"
)
+ try:
+ name, value = line.split(":", 1)
+ except ValueError:
+ raise HTTPInputError("no colon in header line")
+ self.add(name, value.strip(HTTP_WHITESPACE), _chars_are_bytes=_chars_are_bytes)
@classmethod
def parse(cls, headers: str, *, _chars_are_bytes: bool = True) -> "HTTPHeaders":
diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py
index 46300200..4cb2e850 100644
--- a/tornado/test/httpclient_test.py
+++ b/tornado/test/httpclient_test.py
@@ -734,10 +734,14 @@ def test_multi_line_headers(self):
% (resp.body, value, container),
)
def test_multi_line_headers(self):
- # Multi-line http headers are rare but rfc-allowed
- # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2
+ # Line folding is an obsolete way of representing headers split across multiple lines.
+ # https://www.rfc-editor.org/info/rfc9112/#section-5.2
+ #
+ # They must either be folded into a single line or rejected.
+ # Tornado 6.6 removed support for line folding in simple_httpclient, but they are
+ # still supported in libcurl.
sock, port = bind_unused_port()
with closing(sock):
@gen.coroutine
@@ -760,10 +764,15 @@ def test_multi_line_headers(self):
netutil.add_accept_handler(sock, accept_callback) # type: ignore
try:
resp = self.fetch("http://127.0.0.1:%d/" % port)
- resp.rethrow()
- self.assertEqual(resp.headers["X-XSS-Protection"], "1; mode=block")
+ if self.http_client.__class__.__module__ == "tornado.curl_httpclient":
+ resp.rethrow()
+ self.assertEqual(
+ resp.headers["X-XSS-Protection"], "1; mode=block"
+ )
+ else:
+ self.assertEqual(resp.code, 599)
finally:
self.io_loop.remove_handler(sock.fileno())
@gen_test
diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py
index 0e9d5c41..40108d9c 100644
--- a/tornado/test/httputil_test.py
+++ b/tornado/test/httputil_test.py
@@ -384,50 +384,11 @@ Content-Disposition: form-data; name="files"; filename="ab.txt"
class HTTPHeadersTest(unittest.TestCase):
- def test_multi_line(self):
- # Lines beginning with whitespace are appended to the previous line
- # with any leading whitespace replaced by a single space.
- # Note that while multi-line headers are a part of the HTTP spec,
- # their use is strongly discouraged.
- data = """\
-Foo: bar
- baz
-Asdf: qwer
-\tzxcv
-Foo: even
- more
- lines
-""".replace(
- "\n", "\r\n"
- )
- headers = HTTPHeaders.parse(data)
- self.assertEqual(headers["asdf"], "qwer zxcv")
- self.assertEqual(headers.get_list("asdf"), ["qwer zxcv"])
- self.assertEqual(headers["Foo"], "bar baz,even more lines")
- self.assertEqual(headers.get_list("foo"), ["bar baz", "even more lines"])
- self.assertEqual(
- sorted(list(headers.get_all())),
- [("Asdf", "qwer zxcv"), ("Foo", "bar baz"), ("Foo", "even more lines")],
- )
- # Verify case insensitivity in-operator
- self.assertTrue("asdf" in headers)
- self.assertTrue("Asdf" in headers)
-
- def test_continuation(self):
+ def test_folding_rejected(self):
data = "Foo: bar\r\n\tasdf"
- headers = HTTPHeaders.parse(data)
- self.assertEqual(headers["Foo"], "bar asdf")
-
- # If the first line starts with whitespace, it's a
- # continuation line with nothing to continue, so reject it
- # (with a proper error).
- data = " Foo: bar"
- self.assertRaises(HTTPInputError, HTTPHeaders.parse, data)
-
- # \f (formfeed) is whitespace according to str.isspace, but
- # not according to the HTTP spec.
- data = "Foo: bar\r\n\fasdf"
- self.assertRaises(HTTPInputError, HTTPHeaders.parse, data)
+ with self.assertRaises(HTTPInputError):
+ # Folded continuation lines are not supported.
+ HTTPHeaders.parse(data)
def test_forbidden_ascii_characters(self):
# Control characters and ASCII whitespace other than space, tab, and CRLF are not allowed in
--
2.35.6
@@ -0,0 +1,99 @@
From 09591f65286a600c2196df9c33b792b889a6bccb Mon Sep 17 00:00:00 2001
From: Claude <noreply@anthropic.com>
Date: Tue, 6 Oct 2026 14:14:19 +0000
Subject: [PATCH] http1connection: Notify client delegate of header parse
errors
Previously, an HTTPInputError raised while parsing response headers
happened before the delegate was marked as needing on_connection_close,
and close() cleared the stream's close callback, so simple_httpclient
never completed the request and it hung until the timeout. Now the
delegate is told the connection closed, matching the behavior for
errors raised after headers_received.
Update test_multi_line_headers to expect an error from simple_httpclient
now that line folding is no longer supported (curl still accepts it).
CVE: CVE-2026-116577
Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/09591f65286a600c2196df9c33b792b889a6bccb]
Backport Changes:
- Added a supports_line_folding flag to the shared test; Curl keeps the
acceptance check while Simple expects HTTPError for folded response headers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016cNR4NyeuzQ1qFdqCk1yum
Signed-off-by: Yogita Urade <yurade@cisco.com>
---
tornado/http1connection.py | 5 +++++
tornado/test/httpclient_test.py | 19 +++++++++++++------
tornado/test/curl_httpclient_test.py | 2 ++
3 files changed, 20 insertions(+), 6 deletions(-)
diff --git a/tornado/http1connection.py b/tornado/http1connection.py
--- a/tornado/http1connection.py
+++ b/tornado/http1connection.py
@@ -316,6 +316,11 @@ async def _read_message
gen_log.info("Malformed HTTP message from %s: %s", self.context, e)
if not self.is_client:
await self.stream.write(b"HTTP/1.1 400 Bad Request\r\n\r\n")
+ elif not need_delegate_close:
+ # The error came before headers_received, so the delegate
+ # doesn't know about this message yet. Tell it the
+ # connection is closed so a client request doesn't hang.
+ need_delegate_close = True
self.close()
return False
finally:
diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py
--- a/tornado/test/httpclient_test.py
+++ b/tornado/test/httpclient_test.py
@@ -263,6 +263,9 @@ class HTTPClientCommonTestCase
class HTTPClientCommonTestCase(AsyncHTTPTestCase):
+ # Set by subclasses whose client accepts obsolete line folding in
+ # response headers (see test_multi_line_headers).
+ supports_line_folding = False
# Set by subclasses whose client decodes ``Content-Encoding: br``. No
# implementation asks for brotli, so this is only true where the client
# decodes an encoding it did not request (see BrotliBombHandler).
@@ -764,14 +765,18 @@ class HTTPClientCommonTestCase
netutil.add_accept_handler(sock, accept_callback) # type: ignore
try:
- resp = self.fetch("http://127.0.0.1:%d/" % port)
- if self.http_client.__class__.__module__ == "tornado.curl_httpclient":
+ if self.supports_line_folding:
+ resp = self.fetch("http://127.0.0.1:%d/" % port)
resp.rethrow()
- self.assertEqual(
- resp.headers["X-XSS-Protection"], "1; mode=block"
- )
+ self.assertEqual(resp.headers["X-XSS-Protection"], "1; mode=block")
else:
- self.assertEqual(resp.code, 599)
+ with ExpectLog(
+ gen_log, "Malformed HTTP message.*folding", level=logging.INFO
+ ):
+ with self.assertRaises(HTTPError):
+ self.fetch(
+ "http://127.0.0.1:%d/" % port, raise_error=True
+ )
finally:
self.io_loop.remove_handler(sock.fileno())
diff --git a/tornado/test/curl_httpclient_test.py b/tornado/test/curl_httpclient_test.py
--- a/tornado/test/curl_httpclient_test.py
+++ b/tornado/test/curl_httpclient_test.py
@@ -45,6 +45,8 @@ class CurlHTTPClientCommonTestCase
# not we listed it in Accept-Encoding, so brotli is reachable whenever
# libcurl was built with it (which is common).
decompresses_brotli = pycurl is not None and "brotli" in pycurl.version
+ # libcurl still accepts obsolete line folding in response headers.
+ supports_line_folding = True
def get_http_client(self):
client = CurlAsyncHTTPClient(defaults=dict(allow_ipv6=False))
@@ -0,0 +1,101 @@
From 3ef5ec3fc7a158ecd0ca7bfc5fcee42fa2773094 Mon Sep 17 00:00:00 2001
From: Claude <noreply@anthropic.com>
Date: Tue, 6 Oct 2026 15:28:59 +0000
Subject: [PATCH] test: Accept either outcome in test_multi_line_headers
Rather than declaring per-implementation whether line folding is
supported (which was wrong when --httpclient changes the default
client), accept either a correctly unfolded header or a 599 error.
CVE: CVE-2026-116577
Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/3ef5ec3fc7a158ecd0ca7bfc5fcee42fa2773094]
Backport Changes:
- Rebased the test update on p2's 6.5.7-specific test body. Its fetch call and
error assertion use a different line layout, accounting for two additional
deleted lines in httpclient_test.py compared with upstream.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016cNR4NyeuzQ1qFdqCk1yum
Signed-off-by: Yogita Urade <yurade@cisco.com>
---
tornado/test/httpclient_test.py | 37 +++++++++++++++++-----------------
tornado/test/curl_httpclient_test.py | 2 --
2 files changed, 19 insertions(+), 20 deletions(-)
diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py
index bb7de5c4..449f35a4 100644
--- a/tornado/test/httpclient_test.py
+++ b/tornado/test/httpclient_test.py
@@ -263,9 +263,6 @@ class HTTPClientCommonTestCase(AsyncHTTPTestCase):
class HTTPClientCommonTestCase(AsyncHTTPTestCase):
- # Set by subclasses whose client accepts obsolete line folding in
- # response headers (see test_multi_line_headers).
- supports_line_folding = False
# Set by subclasses whose client decodes ``Content-Encoding: br``. No
# implementation asks for brotli, so this is only true where the client
# decodes an encoding it did not request (see BrotliBombHandler).
@@ -741,9 +738,10 @@ def test_multi_line_headers(self):
# Line folding is an obsolete way of representing headers split across multiple lines.
# https://www.rfc-editor.org/info/rfc9112/#section-5.2
#
- # They must either be folded into a single line or rejected.
- # Tornado 6.6 removed support for line folding in simple_httpclient, but they are
- # still supported in libcurl.
+ # The spec requires user agents to unfold them, but simple_httpclient
+ # rejects them as of Tornado 6.6 while libcurl still accepts them. Either
+ # outcome is acceptable here; what matters is that the client doesn't hang
+ # and doesn't misparse the header.
sock, port = bind_unused_port()
with closing(sock):
@@ -767,18 +765,21 @@ def test_multi_line_headers(self):
netutil.add_accept_handler(sock, accept_callback) # type: ignore
try:
- if self.supports_line_folding:
- resp = self.fetch("http://127.0.0.1:%d/" % port)
- resp.rethrow()
- self.assertEqual(resp.headers["X-XSS-Protection"], "1; mode=block")
- else:
- with ExpectLog(
- gen_log, "Malformed HTTP message.*folding", level=logging.INFO
- ):
- with self.assertRaises(HTTPError):
- self.fetch(
- "http://127.0.0.1:%d/" % port, raise_error=True
- )
+ with ExpectLog(
+ gen_log,
+ "Malformed HTTP message.*folding",
+ level=logging.INFO,
+ required=False,
+ ):
+ try:
+ resp = self.fetch("http://127.0.0.1:%d/" % port)
+ except HTTPError as e:
+ self.assertEqual(e.code, 599)
+ else:
+ resp.rethrow()
+ self.assertEqual(
+ resp.headers["X-XSS-Protection"], "1; mode=block"
+ )
finally:
self.io_loop.remove_handler(sock.fileno())
diff --git a/tornado/test/curl_httpclient_test.py b/tornado/test/curl_httpclient_test.py
index 7a53f206..d7eb1adf 100644
--- a/tornado/test/curl_httpclient_test.py
+++ b/tornado/test/curl_httpclient_test.py
@@ -45,8 +45,6 @@ class CurlHTTPClientCommonTestCase
# not we listed it in Accept-Encoding, so brotli is reachable whenever
# libcurl was built with it (which is common).
decompresses_brotli = pycurl is not None and "brotli" in pycurl.version
- # libcurl still accepts obsolete line folding in response headers.
- supports_line_folding = True
def get_http_client(self):
client = CurlAsyncHTTPClient(defaults=dict(allow_ipv6=False))
@@ -16,6 +16,9 @@ SRC_URI += "file://CVE-2026-91990.patch \
file://CVE-2026-103263.patch \
file://CVE-2026-103263-dependent.patch \
file://CVE-2026-115927.patch \
file://CVE-2026-116577_p1.patch \
file://CVE-2026-116577_p2.patch \
file://CVE-2026-116577_p3.patch \
"
inherit pypi python_setuptools_build_meta