Control: tags 1122660 + patch Control: tags 1122660 + pending Control: tags 1122661 + patch Control: tags 1122661 + pending Control: tags 1122663 + patch Control: tags 1122663 + pending
Dear maintainer, I've prepared an NMU for python-tornado (versioned as 6.5.4-0.1) and uploaded it to DELAYED/2. Please feel free to tell me if I should cancel it. cu Adrian
diffstat for python-tornado-6.5.2 python-tornado-6.5.4 debian/changelog | 13 + demos/README.rst | 11 - demos/blog/blog.py | 14 +- demos/blog/templates/base.html | 50 ++++--- demos/chat/chatdemo.py | 8 - demos/facebook/facebook.py | 12 - demos/s3server/s3server.py | 270 ----------------------------------------- docs/caresresolver.rst | 3 docs/releases.rst | 2 docs/releases/v6.5.2.rst | 6 docs/releases/v6.5.3.rst | 33 +++++ docs/releases/v6.5.4.rst | 13 + tornado/__init__.py | 4 tornado/httputil.py | 66 +++++++--- tornado/test/httputil_test.py | 41 ++++++ tornado/test/process_test.py | 4 tornado/test/web_test.py | 15 ++ tornado/web.py | 25 ++- tox.ini | 5 19 files changed, 244 insertions(+), 351 deletions(-) diff -Nru python-tornado-6.5.2/debian/changelog python-tornado-6.5.4/debian/changelog --- python-tornado-6.5.2/debian/changelog 2025-10-04 13:42:36.000000000 +0300 +++ python-tornado-6.5.4/debian/changelog 2026-01-05 13:12:01.000000000 +0200 @@ -1,3 +1,16 @@ +python-tornado (6.5.4-0.1) unstable; urgency=medium + + * Non-maintainer upload. + * New upstream release. + - CVE-2025-67724: Header injection and XSS via reason argument. + (Closes: #1122660) + - CVE-2025-67725: Quadratic DoS via Repeated Header Coalescing. + (Closes: #1122661) + - CVE-2025-67726: Quadratic DoS via Crafted Multipart Parameters. + (Closes: #1122663) + + -- Adrian Bunk <[email protected]> Mon, 05 Jan 2026 13:12:01 +0200 + python-tornado (6.5.2-3) unstable; urgency=medium * Team upload. diff -Nru python-tornado-6.5.2/demos/blog/blog.py python-tornado-6.5.4/demos/blog/blog.py --- python-tornado-6.5.2/demos/blog/blog.py 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/demos/blog/blog.py 2025-12-15 20:42:10.000000000 +0200 @@ -132,6 +132,14 @@ async def any_author_exists(self): return bool(await self.query("SELECT * FROM authors LIMIT 1")) + def redirect_to_next(self): + next = self.get_argument("next", "/") + if next.startswith("//") or not next.startswith("/"): + # Absolute URLs are not allowed because this would be an open redirect + # vulnerability (https://cwe.mitre.org/data/definitions/601.html). + raise tornado.web.HTTPError(400) + self.redirect(next) + class HomeHandler(BaseHandler): async def get(self): @@ -243,7 +251,7 @@ tornado.escape.to_unicode(hashed_password), ) self.set_signed_cookie("blogdemo_user", str(author.id)) - self.redirect(self.get_argument("next", "/")) + self.redirect_to_next() class AuthLoginHandler(BaseHandler): @@ -270,7 +278,7 @@ ) if password_equal: self.set_signed_cookie("blogdemo_user", str(author.id)) - self.redirect(self.get_argument("next", "/")) + self.redirect_to_next() else: self.render("login.html", error="incorrect password") @@ -278,7 +286,7 @@ class AuthLogoutHandler(BaseHandler): def get(self): self.clear_cookie("blogdemo_user") - self.redirect(self.get_argument("next", "/")) + self.redirect_to_next() class EntryModule(tornado.web.UIModule): diff -Nru python-tornado-6.5.2/demos/blog/templates/base.html python-tornado-6.5.4/demos/blog/templates/base.html --- python-tornado-6.5.2/demos/blog/templates/base.html 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/demos/blog/templates/base.html 2025-12-15 20:42:10.000000000 +0200 @@ -1,27 +1,31 @@ <!DOCTYPE html> <html> - <head> - <meta charset="UTF-8"> - <title>{{ escape(handler.settings["blog_title"]) }}</title> - <link rel="stylesheet" href="{{ static_url("blog.css") }}" type="text/css"> - <link rel="alternate" href="/feed" type="application/atom+xml" title="{{ escape(handler.settings["blog_title"]) }}"> - {% block head %}{% end %} - </head> - <body> - <div id="body"> - <div id="header"> - <div style="float:right"> - {% if current_user %} - <a href="/compose">{{ _("New post") }}</a> - - <a href="/auth/logout?next={{ url_escape(request.uri) }}">{{ _("Sign out") }}</a> - {% else %} - {% raw _('<a href="%(url)s">Sign in</a> to compose/edit') % {"url": "/auth/login?next=" + url_escape(request.uri)} %} - {% end %} - </div> - <h1><a href="/">{{ escape(handler.settings["blog_title"]) }}</a></h1> + +<head> + <meta charset="UTF-8"> + <title>{{ escape(handler.settings["blog_title"]) }}</title> + <link rel="stylesheet" href="{{ static_url(" blog.css") }}" type="text/css"> + <link rel="alternate" href="/feed" type="application/atom+xml" title="{{ escape(handler.settings[" blog_title"]) }}"> + {% block head %}{% end %} +</head> + +<body> + <div id="body"> + <div id="header"> + <div style="float:right"> + {% if current_user %} + <a href="/compose">{{ _("New post") }}</a> - + <a href="/auth/logout?next={{ url_escape(request.path) }}">{{ _("Sign out") }}</a> + {% else %} + {% raw _('<a href="%(url)s">Sign in</a> to compose/edit') % {"url": "/auth/login?next=" + + url_escape(request.path)} %} + {% end %} </div> - <div id="content">{% block body %}{% end %}</div> + <h1><a href="/">{{ escape(handler.settings["blog_title"]) }}</a></h1> </div> - {% block bottom %}{% end %} - </body> -</html> + <div id="content">{% block body %}{% end %}</div> + </div> + {% block bottom %}{% end %} +</body> + +</html> \ No newline at end of file diff -Nru python-tornado-6.5.2/demos/chat/chatdemo.py python-tornado-6.5.4/demos/chat/chatdemo.py --- python-tornado-6.5.2/demos/chat/chatdemo.py 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/demos/chat/chatdemo.py 2025-12-15 20:42:10.000000000 +0200 @@ -71,8 +71,12 @@ message["html"] = tornado.escape.to_unicode( self.render_string("message.html", message=message) ) - if self.get_argument("next", None): - self.redirect(self.get_argument("next")) + if next := self.get_argument("next", None): + if next.startswith("//") or not next.startswith("/"): + # Absolute URLs are not allowed because this would be an open redirect + # vulnerability (https://cwe.mitre.org/data/definitions/601.html). + raise tornado.web.HTTPError(400) + self.redirect(next) else: self.write(message) global_message_buffer.add_message(message) diff -Nru python-tornado-6.5.2/demos/facebook/facebook.py python-tornado-6.5.4/demos/facebook/facebook.py --- python-tornado-6.5.2/demos/facebook/facebook.py 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/demos/facebook/facebook.py 2025-12-15 20:42:10.000000000 +0200 @@ -70,13 +70,7 @@ class AuthLoginHandler(BaseHandler, tornado.auth.FacebookGraphMixin): async def get(self): - my_url = ( - self.request.protocol - + "://" - + self.request.host - + "/auth/login?next=" - + tornado.escape.url_escape(self.get_argument("next", "/")) - ) + my_url = self.request.protocol + "://" + self.request.host + "/auth/login" if self.get_argument("code", False): user = await self.get_authenticated_user( redirect_uri=my_url, @@ -85,7 +79,7 @@ code=self.get_argument("code"), ) self.set_signed_cookie("fbdemo_user", tornado.escape.json_encode(user)) - self.redirect(self.get_argument("next", "/")) + self.redirect("/") return self.authorize_redirect( redirect_uri=my_url, @@ -97,7 +91,7 @@ class AuthLogoutHandler(BaseHandler, tornado.auth.FacebookGraphMixin): def get(self): self.clear_cookie("fbdemo_user") - self.redirect(self.get_argument("next", "/")) + self.redirect("/") class PostModule(tornado.web.UIModule): diff -Nru python-tornado-6.5.2/demos/README.rst python-tornado-6.5.4/demos/README.rst --- python-tornado-6.5.2/demos/README.rst 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/demos/README.rst 2025-12-15 20:42:10.000000000 +0200 @@ -5,16 +5,6 @@ various Tornado features. If you're not sure where to start, try the ``chat``, ``blog``, or ``websocket`` demos. -.. note:: - - These applications require features due to be introduced in Tornado 6.3 - which is not yet released. Unless you are testing the new release, - use the GitHub branch selector to access the ``stable`` branch - (or the ``branchX.y`` branch corresponding to the version of Tornado you - are using) to get a suitable version of the demos. - - TODO: remove this when 6.3 ships. - Web Applications ~~~~~~~~~~~~~~~~ @@ -24,7 +14,6 @@ - ``websocket``: Similar to ``chat`` but with WebSockets instead of long polling. - ``helloworld``: The simplest possible Tornado web page. -- ``s3server``: Implements a basic subset of the Amazon S3 API. Feature demos ~~~~~~~~~~~~~ diff -Nru python-tornado-6.5.2/demos/s3server/s3server.py python-tornado-6.5.4/demos/s3server/s3server.py --- python-tornado-6.5.2/demos/s3server/s3server.py 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/demos/s3server/s3server.py 1970-01-01 02:00:00.000000000 +0200 @@ -1,270 +0,0 @@ -# -# Copyright 2009 Facebook -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -"""Implementation of an S3-like storage server based on local files. - -Useful to test features that will eventually run on S3, or if you want to -run something locally that was once running on S3. - -We don't support all the features of S3, but it does work with the -standard S3 client for the most basic semantics. To use the standard -S3 client with this module: - - c = S3.AWSAuthConnection("", "", server="localhost", port=8888, - is_secure=False) - c.create_bucket("mybucket") - c.put("mybucket", "mykey", "a value") - print c.get("mybucket", "mykey").body - -""" - -import asyncio -import bisect -import datetime -import hashlib -import os -import os.path -import urllib - -from tornado import escape -from tornado import httpserver -from tornado import web -from tornado.util import unicode_type -from tornado.options import options, define - -try: - long -except NameError: - long = int - -define("port", default=9888, help="TCP port to listen on") -define("root_directory", default="/tmp/s3", help="Root storage directory") -define("bucket_depth", default=0, help="Bucket file system depth limit") - - -async def start(port, root_directory, bucket_depth): - """Starts the mock S3 server on the given port at the given path.""" - application = S3Application(root_directory, bucket_depth) - http_server = httpserver.HTTPServer(application) - http_server.listen(port) - await asyncio.Event().wait() - - -class S3Application(web.Application): - """Implementation of an S3-like storage server based on local files. - - If bucket depth is given, we break files up into multiple directories - to prevent hitting file system limits for number of files in each - directories. 1 means one level of directories, 2 means 2, etc. - """ - - def __init__(self, root_directory, bucket_depth=0): - web.Application.__init__( - self, - [ - (r"/", RootHandler), - (r"/([^/]+)/(.+)", ObjectHandler), - (r"/([^/]+)/", BucketHandler), - ], - ) - self.directory = os.path.abspath(root_directory) - if not os.path.exists(self.directory): - os.makedirs(self.directory) - self.bucket_depth = bucket_depth - - -class BaseRequestHandler(web.RequestHandler): - SUPPORTED_METHODS = ("PUT", "GET", "DELETE") - - def render_xml(self, value): - assert isinstance(value, dict) and len(value) == 1 - self.set_header("Content-Type", "application/xml; charset=UTF-8") - name = list(value.keys())[0] - parts = [] - parts.append("<" + name + ' xmlns="http://doc.s3.amazonaws.com/2006-03-01">') - self._render_parts(value[name], parts) - parts.append("</" + name + ">") - self.finish('<?xml version="1.0" encoding="UTF-8"?>\n' + "".join(parts)) - - def _render_parts(self, value, parts=[]): - if isinstance(value, (unicode_type, bytes)): - parts.append(escape.xhtml_escape(value)) - elif isinstance(value, (int, long)): - parts.append(str(value)) - elif isinstance(value, datetime.datetime): - parts.append(value.strftime("%Y-%m-%dT%H:%M:%S.000Z")) - elif isinstance(value, dict): - for name, subvalue in value.items(): - if not isinstance(subvalue, list): - subvalue = [subvalue] - for subsubvalue in subvalue: - parts.append("<" + name + ">") - self._render_parts(subsubvalue, parts) - parts.append("</" + name + ">") - else: - raise Exception("Unknown S3 value type %r", value) - - def _object_path(self, bucket, object_name): - if self.application.bucket_depth < 1: - return os.path.abspath( - os.path.join(self.application.directory, bucket, object_name) - ) - hash = hashlib.md5(object_name).hexdigest() - path = os.path.abspath(os.path.join(self.application.directory, bucket)) - for i in range(self.application.bucket_depth): - path = os.path.join(path, hash[: 2 * (i + 1)]) - return os.path.join(path, object_name) - - -class RootHandler(BaseRequestHandler): - def get(self): - names = os.listdir(self.application.directory) - buckets = [] - for name in names: - path = os.path.join(self.application.directory, name) - info = os.stat(path) - buckets.append( - { - "Name": name, - "CreationDate": datetime.datetime.fromtimestamp( - info.st_ctime, datetime.timezone.utc - ), - } - ) - self.render_xml({"ListAllMyBucketsResult": {"Buckets": {"Bucket": buckets}}}) - - -class BucketHandler(BaseRequestHandler): - def get(self, bucket_name): - prefix = self.get_argument("prefix", "") - marker = self.get_argument("marker", "") - max_keys = int(self.get_argument("max-keys", 50000)) - path = os.path.abspath(os.path.join(self.application.directory, bucket_name)) - terse = int(self.get_argument("terse", 0)) - if not path.startswith(self.application.directory) or not os.path.isdir(path): - raise web.HTTPError(404) - object_names = [] - for root, dirs, files in os.walk(path): - for file_name in files: - object_names.append(os.path.join(root, file_name)) - skip = len(path) + 1 - for i in range(self.application.bucket_depth): - skip += 2 * (i + 1) + 1 - object_names = [n[skip:] for n in object_names] - object_names.sort() - contents = [] - - start_pos = 0 - if marker: - start_pos = bisect.bisect_right(object_names, marker, start_pos) - if prefix: - start_pos = bisect.bisect_left(object_names, prefix, start_pos) - - truncated = False - for object_name in object_names[start_pos:]: - if not object_name.startswith(prefix): - break - if len(contents) >= max_keys: - truncated = True - break - object_path = self._object_path(bucket_name, object_name) - c = {"Key": object_name} - if not terse: - info = os.stat(object_path) - c.update( - { - "LastModified": datetime.datetime.utcfromtimestamp( - info.st_mtime - ), - "Size": info.st_size, - } - ) - contents.append(c) - marker = object_name - self.render_xml( - { - "ListBucketResult": { - "Name": bucket_name, - "Prefix": prefix, - "Marker": marker, - "MaxKeys": max_keys, - "IsTruncated": truncated, - "Contents": contents, - } - } - ) - - def put(self, bucket_name): - path = os.path.abspath(os.path.join(self.application.directory, bucket_name)) - if not path.startswith(self.application.directory) or os.path.exists(path): - raise web.HTTPError(403) - os.makedirs(path) - self.finish() - - def delete(self, bucket_name): - path = os.path.abspath(os.path.join(self.application.directory, bucket_name)) - if not path.startswith(self.application.directory) or not os.path.isdir(path): - raise web.HTTPError(404) - if len(os.listdir(path)) > 0: - raise web.HTTPError(403) - os.rmdir(path) - self.set_status(204) - self.finish() - - -class ObjectHandler(BaseRequestHandler): - def get(self, bucket, object_name): - object_name = urllib.unquote(object_name) - path = self._object_path(bucket, object_name) - if not path.startswith(self.application.directory) or not os.path.isfile(path): - raise web.HTTPError(404) - info = os.stat(path) - self.set_header("Content-Type", "application/unknown") - self.set_header( - "Last-Modified", datetime.datetime.utcfromtimestamp(info.st_mtime) - ) - with open(path, "rb") as object_file: - self.finish(object_file.read()) - - def put(self, bucket, object_name): - object_name = urllib.unquote(object_name) - bucket_dir = os.path.abspath(os.path.join(self.application.directory, bucket)) - if not bucket_dir.startswith(self.application.directory) or not os.path.isdir( - bucket_dir - ): - raise web.HTTPError(404) - path = self._object_path(bucket, object_name) - if not path.startswith(bucket_dir) or os.path.isdir(path): - raise web.HTTPError(403) - directory = os.path.dirname(path) - if not os.path.exists(directory): - os.makedirs(directory) - with open(path, "w") as object_file: - object_file.write(self.request.body) - self.finish() - - def delete(self, bucket, object_name): - object_name = urllib.unquote(object_name) - path = self._object_path(bucket, object_name) - if not path.startswith(self.application.directory) or not os.path.isfile(path): - raise web.HTTPError(404) - os.unlink(path) - self.set_status(204) - self.finish() - - -if __name__ == "__main__": - options.parse_command_line() - asyncio.run(start(options.port, options.root_directory, options.bucket_depth)) diff -Nru python-tornado-6.5.2/docs/caresresolver.rst python-tornado-6.5.4/docs/caresresolver.rst --- python-tornado-6.5.2/docs/caresresolver.rst 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/docs/caresresolver.rst 2025-12-15 20:42:10.000000000 +0200 @@ -19,6 +19,9 @@ the default for ``tornado.simple_httpclient``, but other libraries may default to ``AF_UNSPEC``. + This class requires ``pycares`` version 4. Since this class is deprecated, it will not be + updated to support ``pycares`` version 5. + .. deprecated:: 6.2 This class is deprecated and will be removed in Tornado 7.0. Use the default thread-based resolver instead. diff -Nru python-tornado-6.5.2/docs/releases/v6.5.2.rst python-tornado-6.5.4/docs/releases/v6.5.2.rst --- python-tornado-6.5.2/docs/releases/v6.5.2.rst 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/docs/releases/v6.5.2.rst 2025-12-15 20:42:10.000000000 +0200 @@ -8,10 +8,10 @@ ~~~~~~~~~ - Fixed a bug that resulted in WebSocket pings not being sent at the configured interval. -- Improved logging for invalid ``Host`` headers. This was previouisly logged as an uncaught +- Improved logging for invalid ``Host`` headers. This was previously logged as an uncaught exception with a stack trace, now it is simply a 400 response (logged as a warning in the - access log) -- Restored the ``host`` argument to ``.HTTPServerRequest``. This argument is deprecated + access log). +- Restored the ``host`` argument to `.HTTPServerRequest`. This argument is deprecated and will be removed in the future, but its removal with no warning in 6.5.0 was a mistake. - Removed a debugging print statement that was left in the code. - Improved type hints for ``gen.multi``. \ No newline at end of file diff -Nru python-tornado-6.5.2/docs/releases/v6.5.3.rst python-tornado-6.5.4/docs/releases/v6.5.3.rst --- python-tornado-6.5.2/docs/releases/v6.5.3.rst 1970-01-01 02:00:00.000000000 +0200 +++ python-tornado-6.5.4/docs/releases/v6.5.3.rst 2025-12-15 20:42:10.000000000 +0200 @@ -0,0 +1,33 @@ +What's new in Tornado 6.5.3 +=========================== + +Dec 10, 2025 +------------ + +Security fixes +~~~~~~~~~~~~~~ +- Fixed a denial-of-service vulnerability involving quadratic computation when parsing + ``multipart/form-data`` request bodies. + `CVE-2025-67726 <https://github.com/tornadoweb/tornado/security/advisories/GHSA-jhmp-mqwm-3gq8>`_ + Thanks to `Finder16 <https://github.com/Finder16>`_ for reporting this issue. +- Fixed a denial-of-service vulnerability involving quadratic computation when parsing repeated HTTP + headers. + `CVE-2025-67725 <https://github.com/tornadoweb/tornado/security/advisories/GHSA-c98p-7wgm-6p64>`_. + Thanks to `Finder16 <https://github.com/Finder16>`_ for reporting this issue. +- Fixed a header injection and XSS vulnerability involving the ``reason`` argument to + `.RequestHandler.set_status` and `tornado.web.HTTPError`. + `CVE-2025-67724 <https://github.com/tornadoweb/tornado/security/advisories/GHSA-pr2v-jx2c-wg9f>`_. + Thanks to `Finder16 <https://github.com/Finder16>`_ and + `Cheshire1225 <https://github.com/Cheshire1225>`_ for reporting this issue. + +Demo changes +~~~~~~~~~~~~ +- Several demo applications bundled with the Tornado repo (``blog``, ``chat``, ``facebook``) had an + open redirect vulnerability which has been fixed. This is not covered by a CVE or security + advisory since the demo applications are not included as a part of the Tornado package when + installed, but developers who have copied code from these demos may which to review their own + applications for open redirects. Thanks to `J1vvoo <https://github.com/J1vvoo>`_ for reporting this + issue. +- The ``s3server`` demo application contained some path traversal vulnerabilities. Since this demo + application was not demonstrating any interesting aspects of Tornado, it has been deleted rather + than being fixed. Thanks to `J1vvoo <https://github.com/J1vvoo>`_ for reporting this issue. diff -Nru python-tornado-6.5.2/docs/releases/v6.5.4.rst python-tornado-6.5.4/docs/releases/v6.5.4.rst --- python-tornado-6.5.2/docs/releases/v6.5.4.rst 1970-01-01 02:00:00.000000000 +0200 +++ python-tornado-6.5.4/docs/releases/v6.5.4.rst 2025-12-15 20:42:10.000000000 +0200 @@ -0,0 +1,13 @@ +What's new in Tornado 6.5.4 +=========================== + +Dec 15, 2025 +------------ + +Bug fixes +~~~~~~~~~ + +- The ``in`` operator for ``HTTPHeaders`` was incorrectly case-sensitive, causing + lookups to fail for headers with different casing than the original header name. + This was a regression in version 6.5.3 and has been fixed to restore the intended + case-insensitive behavior from version 6.5.2 and earlier. diff -Nru python-tornado-6.5.2/docs/releases.rst python-tornado-6.5.4/docs/releases.rst --- python-tornado-6.5.2/docs/releases.rst 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/docs/releases.rst 2025-12-15 20:42:10.000000000 +0200 @@ -4,6 +4,8 @@ .. toctree:: :maxdepth: 2 + releases/v6.5.4 + releases/v6.5.3 releases/v6.5.2 releases/v6.5.1 releases/v6.5.0 diff -Nru python-tornado-6.5.2/tornado/httputil.py python-tornado-6.5.4/tornado/httputil.py --- python-tornado-6.5.2/tornado/httputil.py 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/tornado/httputil.py 2025-12-15 20:42:10.000000000 +0200 @@ -187,8 +187,14 @@ 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 @@ -215,9 +221,7 @@ 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 @@ -278,7 +282,7 @@ 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._dict[self._last_key] += new_part + self._combined_cache.pop(self._last_key, None) else: try: name, value = line.split(":", 1) @@ -333,22 +337,33 @@ 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 + norm_name = _normalize_header(name) + return norm_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. @@ -1080,19 +1095,34 @@ # 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 -Nru python-tornado-6.5.2/tornado/__init__.py python-tornado-6.5.4/tornado/__init__.py --- python-tornado-6.5.2/tornado/__init__.py 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/tornado/__init__.py 2025-12-15 20:42:10.000000000 +0200 @@ -22,8 +22,8 @@ # is zero for an official release, positive for a development branch, # or negative for a release candidate or beta (after the base version # number has been incremented) -version = "6.5.2" -version_info = (6, 5, 2, 0) +version = "6.5.4" +version_info = (6, 5, 4, 0) import importlib import typing diff -Nru python-tornado-6.5.2/tornado/test/httputil_test.py python-tornado-6.5.4/tornado/test/httputil_test.py --- python-tornado-6.5.2/tornado/test/httputil_test.py 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/tornado/test/httputil_test.py 2025-12-15 20:42:10.000000000 +0200 @@ -279,6 +279,29 @@ 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.perf_counter() + 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.perf_counter() - 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): @@ -306,6 +329,9 @@ 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): data = "Foo: bar\r\n\tasdf" @@ -471,6 +497,21 @@ with self.assertRaises(HTTPInputError): headers.add(name, "bar") + def test_linear_performance(self): + def f(n): + start = time.perf_counter() + headers = HTTPHeaders() + for i in range(n): + headers.add("X-Foo", "bar") + return time.perf_counter() - 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. diff -Nru python-tornado-6.5.2/tornado/test/process_test.py python-tornado-6.5.4/tornado/test/process_test.py --- python-tornado-6.5.2/tornado/test/process_test.py 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/tornado/test/process_test.py 2025-12-15 20:42:10.000000000 +0200 @@ -141,7 +141,7 @@ @gen_test def test_subprocess(self): subproc = Subprocess( - [sys.executable, "-u", "-i"], + [sys.executable, "-u", "-i", "-I"], stdin=Subprocess.STREAM, stdout=Subprocess.STREAM, stderr=subprocess.STDOUT, @@ -163,7 +163,7 @@ def test_close_stdin(self): # Close the parent's stdin handle and see that the child recognizes it. subproc = Subprocess( - [sys.executable, "-u", "-i"], + [sys.executable, "-u", "-i", "-I"], stdin=Subprocess.STREAM, stdout=Subprocess.STREAM, stderr=subprocess.STDOUT, diff -Nru python-tornado-6.5.2/tornado/test/web_test.py python-tornado-6.5.4/tornado/test/web_test.py --- python-tornado-6.5.2/tornado/test/web_test.py 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/tornado/test/web_test.py 2025-12-15 20:42:10.000000000 +0200 @@ -1746,7 +1746,7 @@ 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, ) @@ -1769,6 +1769,19 @@ 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 -Nru python-tornado-6.5.2/tornado/web.py python-tornado-6.5.4/tornado/web.py --- python-tornado-6.5.2/tornado/web.py 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/tornado/web.py 2025-12-15 20:42:10.000000000 +0200 @@ -359,8 +359,10 @@ :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 @@ -369,6 +371,14 @@ """ self._status_code = status_code if reason is not None: + if "<" in reason or not httputil._ABNF.reason_phrase.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") @@ -1345,7 +1355,8 @@ 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: @@ -1373,7 +1384,7 @@ 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 @@ -2520,9 +2531,11 @@ 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__( diff -Nru python-tornado-6.5.2/tox.ini python-tornado-6.5.4/tox.ini --- python-tornado-6.5.2/tox.ini 2025-08-08 20:53:08.000000000 +0300 +++ python-tornado-6.5.4/tox.ini 2025-12-15 20:42:10.000000000 +0200 @@ -35,7 +35,10 @@ deps = full: pycurl full: twisted - full: pycares + # Pycares 5 has some backwards-incompatible changes that we don't support. + # And since CaresResolver is deprecated, I do not expect to fix it, so just + # pin the previous version. (This should really be in requirements.{in,txt} instead) + full: pycares<5 docs: -r{toxinidir}/requirements.txt lint: -r{toxinidir}/requirements.txt

