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
 

Reply via email to