Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
python-tornado (6.4.2-3+deb13u2) trixie-security; urgency=medium

* Backport 87a85bb4de18ab17c360f7cb530274202fd05d4c to fix a
regression in the fix for CVE-2025-67725, spotted by the
python-jaeger autopkgtest, with thanks to Thomas Goirand
in the help tracking this down.

-- Moritz Muehlenhoff <jmm@debian.org> Thu, 02 Apr 2026 22:44:44 +0200

python-tornado (6.4.2-3+deb13u1) trixie-security; urgency=medium

* Non-maintainer upload by the Debian LTS team.
* d/patches/CVE-2025-67726.patch: Add patch to fix CVE-2025-67726.
- Fix an inefficient algorithm when parsing parameters for HTTP header
values, potentially causing a DoS (closes: #1122663).
* d/patches/CVE-2025-67725.patch: Add patch to fix CVE-2025-67725.
- Fix possible DoS due to quadratic performance of repeated header
lines (closes: #1122661).
* d/patches/CVE-2025-67724.patch: Add patch to fix CVE-2025-67724.
- Fix multiple vulnerabilities caused by custom reason phrases being
used unescaped in HTTP headers (closes: #1122660).

-- Daniel Leidert <dleidert@debian.org> Sun, 01 Feb 2026 00:34:03 +0100

python-tornado (6.4.2-3) unstable; urgency=medium

* Team upload.
Expand Down
4 changes: 4 additions & 0 deletions debian/gbp.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[DEFAULT]
upstream-branch = upstream
debian-branch = debian/trixie
pristine-tar = True
129 changes: 129 additions & 0 deletions debian/patches/CVE-2025-67724.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
From: Ben Darnell <ben@bendarnell.com>
Date: Wed, 10 Dec 2025 15:15:25 -0500
Subject: web: Harden against invalid HTTP reason phrases

We allow applications to set custom reason phrases for the HTTP status
line (to support custom status codes), but if this were exposed to
untrusted data it could be exploited in various ways. This commit
guards against invalid reason phrases in both HTTP headers and in
error pages.

Reviewed-By: Daniel Leidert <dleidert@debian.org>
Origin: https://github.com/tornadoweb/tornado/commit/f3b99cd34d4c6360f0db34b3c39f700c002b1415
Bug: https://github.com/tornadoweb/tornado/security/advisories/GHSA-pr2v-jx2c-wg9f
Bug-Debian: https://bugs.debian.org/1122660
Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-67724
Bug-Freexian-Security: https://deb.freexian.com/extended-lts/tracker/CVE-2025-67724
---
tornado/test/web_test.py | 15 ++++++++++++++-
tornado/web.py | 28 ++++++++++++++++++++++------
2 files changed, 36 insertions(+), 7 deletions(-)

diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py
index fec66f3..801a80e 100644
--- a/tornado/test/web_test.py
+++ b/tornado/test/web_test.py
@@ -1712,7 +1712,7 @@ class StatusReasonTest(SimpleHandlerTestCase):
class Handler(RequestHandler):
def get(self):
reason = self.request.arguments.get("reason", [])
- self.set_status(
+ raise HTTPError(
int(self.get_argument("code")),
reason=to_unicode(reason[0]) if reason else None,
)
@@ -1735,6 +1735,19 @@ class StatusReasonTest(SimpleHandlerTestCase):
self.assertEqual(response.code, 682)
self.assertEqual(response.reason, "Unknown")

+ def test_header_injection(self):
+ response = self.fetch("/?code=200&reason=OK%0D%0AX-Injection:injected")
+ self.assertEqual(response.code, 200)
+ self.assertEqual(response.reason, "Unknown")
+ self.assertNotIn("X-Injection", response.headers)
+
+ def test_reason_xss(self):
+ response = self.fetch("/?code=400&reason=<script>alert(1)</script>")
+ self.assertEqual(response.code, 400)
+ self.assertEqual(response.reason, "Unknown")
+ self.assertNotIn(b"script", response.body)
+ self.assertIn(b"Unknown", response.body)
+

class DateHeaderTest(SimpleHandlerTestCase):
class Handler(RequestHandler):
diff --git a/tornado/web.py b/tornado/web.py
index 8ec5601..409d8c5 100644
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -180,6 +180,9 @@ class _ArgDefaultMarker:
_ARG_DEFAULT = _ArgDefaultMarker()


+reason_phrase_re = re.compile(r"(?:[\t ]|[\x21-\x7E]|[\x80-\xFF])+")
+
+
class RequestHandler(object):
"""Base class for HTTP request handlers.

@@ -350,8 +353,10 @@ class RequestHandler(object):

:arg int status_code: Response status code.
:arg str reason: Human-readable reason phrase describing the status
- code. If ``None``, it will be filled in from
- `http.client.responses` or "Unknown".
+ code (for example, the "Not Found" in ``HTTP/1.1 404 Not Found``).
+ Normally determined automatically from `http.client.responses`; this
+ argument should only be used if you need to use a non-standard
+ status code.

.. versionchanged:: 5.0

@@ -360,6 +365,14 @@ class RequestHandler(object):
"""
self._status_code = status_code
if reason is not None:
+ if "<" in reason or not reason_phrase_re.fullmatch(reason):
+ # Logically this would be better as an exception, but this method
+ # is called on error-handling paths that would need some refactoring
+ # to tolerate internal errors cleanly.
+ #
+ # The check for "<" is a defense-in-depth against XSS attacks (we also
+ # escape the reason when rendering error pages).
+ reason = "Unknown"
self._reason = escape.native_str(reason)
else:
self._reason = httputil.responses.get(status_code, "Unknown")
@@ -1295,7 +1308,8 @@ class RequestHandler(object):
reason = exception.reason
self.set_status(status_code, reason=reason)
try:
- self.write_error(status_code, **kwargs)
+ if status_code != 304:
+ self.write_error(status_code, **kwargs)
except Exception:
app_log.error("Uncaught exception in write_error", exc_info=True)
if not self._finished:
@@ -1323,7 +1337,7 @@ class RequestHandler(object):
self.finish(
"<html><title>%(code)d: %(message)s</title>"
"<body>%(code)d: %(message)s</body></html>"
- % {"code": status_code, "message": self._reason}
+ % {"code": status_code, "message": escape.xhtml_escape(self._reason)}
)

@property
@@ -2469,9 +2483,11 @@ class HTTPError(Exception):
mode). May contain ``%s``-style placeholders, which will be filled
in with remaining positional parameters.
:arg str reason: Keyword-only argument. The HTTP "reason" phrase
- to pass in the status line along with ``status_code``. Normally
+ to pass in the status line along with ``status_code`` (for example,
+ the "Not Found" in ``HTTP/1.1 404 Not Found``). Normally
determined automatically from ``status_code``, but can be used
- to use a non-standard numeric code.
+ to use a non-standard numeric code. This is not a general-purpose
+ error message.
"""

def __init__(
130 changes: 130 additions & 0 deletions debian/patches/CVE-2025-67725.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
From: Ben Darnell <ben@bendarnell.com>
Date: Tue, 9 Dec 2025 13:27:27 -0500
Subject: [PATCH] httputil: Fix quadratic performance of repeated header lines

Previouisly, when many header lines with the same name were found
in an HTTP request or response, repeated string concatenation would
result in quadratic performance. This change does the concatenation
lazily (with a cache) so that repeated headers can be processed
efficiently.

Security: The previous behavior allowed a denial of service attack
via a maliciously crafted HTTP message, but only if the
max_header_size was increased from its default of 64kB.

Reviewed-By: Daniel Leidert <dleidert@debian.org>
Origin: https://github.com/tornadoweb/tornado/commit/68e81b4a3385161877408a7a49c7ed12b45a614d
Bug: https://github.com/tornadoweb/tornado/security/advisories/GHSA-c98p-7wgm-6p64
Bug-Debian: https://bugs.debian.org/1122661
Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-67725
Bug-Freexian-Security: https://deb.freexian.com/extended-lts/tracker/CVE-2025-67725
---
tornado/httputil.py | 36 +++++++++++++++++++++++++-----------
tornado/test/httputil_test.py | 15 +++++++++++++++
2 files changed, 40 insertions(+), 11 deletions(-)

diff --git a/tornado/httputil.py b/tornado/httputil.py
index bbacd4a..2824e1e 100644
--- a/tornado/httputil.py
+++ b/tornado/httputil.py
@@ -121,8 +121,14 @@ class HTTPHeaders(collections.abc.MutableMapping):
pass

def __init__(self, *args: typing.Any, **kwargs: str) -> None: # noqa: F811
- self._dict = {} # type: typing.Dict[str, str]
- self._as_list = {} # type: typing.Dict[str, typing.List[str]]
+ # Formally, HTTP headers are a mapping from a field name to a "combined field value",
+ # which may be constructed from multiple field lines by joining them with commas.
+ # In practice, however, some headers (notably Set-Cookie) do not follow this convention,
+ # so we maintain a mapping from field name to a list of field lines in self._as_list.
+ # self._combined_cache is a cache of the combined field values derived from self._as_list
+ # 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
@@ -139,9 +145,7 @@ class HTTPHeaders(collections.abc.MutableMapping):
norm_name = _normalize_header(name)
self._last_key = norm_name
if norm_name in self:
- self._dict[norm_name] = (
- native_str(self[norm_name]) + "," + native_str(value)
- )
+ self._combined_cache.pop(norm_name, None)
self._as_list[norm_name].append(value)
else:
self[norm_name] = value
@@ -175,7 +179,7 @@ class HTTPHeaders(collections.abc.MutableMapping):
raise HTTPInputError("first header line cannot start with whitespace")
new_part = " " + line.lstrip(HTTP_WHITESPACE)
self._as_list[self._last_key][-1] += new_part
- self._dict[self._last_key] += new_part
+ self._combined_cache.pop(self._last_key, None)
else:
try:
name, value = line.split(":", 1)
@@ -211,22 +215,32 @@ class HTTPHeaders(collections.abc.MutableMapping):

def __setitem__(self, name: str, value: str) -> None:
norm_name = _normalize_header(name)
- self._dict[norm_name] = value
+ self._combined_cache[norm_name] = value
self._as_list[norm_name] = [value]

+ def __contains__(self, name: object) -> bool:
+ # This is an important optimization to avoid the expensive concatenation
+ # in __getitem__ when it's not needed.
+ if not isinstance(name, str):
+ return False
+ return name in self._as_list
+
def __getitem__(self, name: str) -> str:
- return self._dict[_normalize_header(name)]
+ header = _normalize_header(name)
+ if header not in self._combined_cache:
+ self._combined_cache[header] = ",".join(self._as_list[header])
+ return self._combined_cache[header]

def __delitem__(self, name: str) -> None:
norm_name = _normalize_header(name)
- del self._dict[norm_name]
+ del self._combined_cache[norm_name]
del self._as_list[norm_name]

def __len__(self) -> int:
- return len(self._dict)
+ return len(self._as_list)

def __iter__(self) -> Iterator[typing.Any]:
- return iter(self._dict)
+ return iter(self._as_list)

def copy(self) -> "HTTPHeaders":
# defined in dict but not in MutableMapping.
diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py
index 22b1681..071fa0f 100644
--- a/tornado/test/httputil_test.py
+++ b/tornado/test/httputil_test.py
@@ -435,6 +435,21 @@ Foo: even
headers2 = HTTPHeaders.parse(str(headers))
self.assertEqual(headers, headers2)

+ def test_linear_performance(self):
+ def f(n):
+ start = time.time()
+ headers = HTTPHeaders()
+ for i in range(n):
+ headers.add("X-Foo", "bar")
+ return time.time() - start
+
+ # This runs under 50ms on my laptop as of 2025-12-09.
+ d1 = f(10_000)
+ d2 = f(100_000)
+ if d2 / d1 > 20:
+ # d2 should be about 10x d1 but allow a wide margin for variability.
+ self.fail(f"HTTPHeaders.add() does not scale linearly: {d1=} vs {d2=}")
+

class FormatTimestampTest(unittest.TestCase):
# Make sure that all the input types are supported.
100 changes: 100 additions & 0 deletions debian/patches/CVE-2025-67726.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
From: Ben Darnell <ben@bendarnell.com>
Date: Wed, 10 Dec 2025 10:55:02 -0500
Subject: httputil: Fix quadratic behavior in _parseparam

Prior to this change, _parseparam had O(n^2) behavior when parsing
certain inputs, which could be a DoS vector. This change adapts
logic from the equivalent function in the python standard library
in https://github.com/python/cpython/pull/136072/files

Reviewed-By: Daniel Leidert <dleidert@debian.org>
Origin: https://github.com/tornadoweb/tornado/pull/3554
Bug: https://github.com/tornadoweb/tornado/security/advisories/GHSA-jhmp-mqwm-3gq8
Bug-Debian: https://bugs.debian.org/1122663
Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-67726
Bug-Freexian-Security: https://deb.freexian.com/extended-lts/tracker/CVE-2025-67726
---
tornado/httputil.py | 29 ++++++++++++++++++++++-------
tornado/test/httputil_test.py | 23 +++++++++++++++++++++++
2 files changed, 45 insertions(+), 7 deletions(-)

diff --git a/tornado/httputil.py b/tornado/httputil.py
index 090a977..bbacd4a 100644
--- a/tornado/httputil.py
+++ b/tornado/httputil.py
@@ -926,19 +926,34 @@ def parse_response_start_line(line: str) -> ResponseStartLine:
# It has also been modified to support valueless parameters as seen in
# websocket extension negotiations, and to support non-ascii values in
# RFC 2231/5987 format.
+#
+# _parseparam has been further modified with the logic from
+# https://github.com/python/cpython/pull/136072/files
+# to avoid quadratic behavior when parsing semicolons in quoted strings.
+#
+# TODO: See if we can switch to email.message.Message for this functionality.
+# This is the suggested replacement for the cgi.py module now that cgi has
+# been removed from recent versions of Python. We need to verify that
+# the email module is consistent with our existing behavior (and all relevant
+# RFCs for multipart/form-data) before making this change.


def _parseparam(s: str) -> Generator[str, None, None]:
- while s[:1] == ";":
- s = s[1:]
- end = s.find(";")
- while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2:
- end = s.find(";", end + 1)
+ start = 0
+ while s.find(";", start) == start:
+ start += 1
+ end = s.find(";", start)
+ ind, diff = start, 0
+ while end > 0:
+ diff += s.count('"', ind, end) - s.count('\\"', ind, end)
+ if diff % 2 == 0:
+ break
+ end, ind = ind, s.find(";", end + 1)
if end < 0:
end = len(s)
- f = s[:end]
+ f = s[start:end]
yield f.strip()
- s = s[end:]
+ start = end


def _parse_header(line: str) -> Tuple[str, Dict[str, str]]:
diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py
index 9494d0c..22b1681 100644
--- a/tornado/test/httputil_test.py
+++ b/tornado/test/httputil_test.py
@@ -262,6 +262,29 @@ Foo
self.assertEqual(file["filename"], "ab.txt")
self.assertEqual(file["body"], b"Foo")

+ def test_disposition_param_linear_performance(self):
+ # This is a regression test for performance of parsing parameters
+ # to the content-disposition header, specifically for semicolons within
+ # quoted strings.
+ def f(n):
+ start = time.time()
+ message = (
+ b"--1234\r\nContent-Disposition: form-data; "
+ + b'x="'
+ + b";" * n
+ + b'"; '
+ + b'name="files"; filename="a.txt"\r\n\r\nFoo\r\n--1234--\r\n'
+ )
+ args: dict[str, list[bytes]] = {}
+ files: dict[str, list[HTTPFile]] = {}
+ parse_multipart_form_data(b"1234", message, args, files)
+ return time.time() - start
+
+ d1 = f(1_000)
+ d2 = f(10_000)
+ if d2 / d1 > 20:
+ self.fail(f"Disposition param parsing is not linear: {d1=} vs {d2=}")
+

class HTTPHeadersTest(unittest.TestCase):
def test_multi_line(self):
Loading
Loading