Control: tags 1138864 + patch
Control: tags 1138864 + pending

Dear maintainer,

I've prepared an NMU for python-daphne (versioned as 4.2.2-0.1) and 
uploaded it to DELAYED/2. Please feel free to tell me if I should
cancel it.

cu
Adrian
diffstat for python-daphne-4.2.1 python-daphne-4.2.2

 .github/workflows/tests.yml |    4 -
 .gitignore                  |    1 
 .pre-commit-config.yaml     |    4 -
 CHANGELOG.txt               |   31 ++++++++++
 daphne/__init__.py          |    2 
 daphne/cli.py               |   18 +++++
 daphne/http_protocol.py     |   12 ++-
 daphne/server.py            |    6 +
 daphne/testing.py           |   15 ++++
 daphne/utils.py             |    3 
 debian/changelog            |   10 +++
 tests/test_websocket.py     |  133 ++++++++++++++++++++++++++++++++++++++++++++
 12 files changed, 230 insertions(+), 9 deletions(-)

diff -Nru python-daphne-4.2.1/CHANGELOG.txt python-daphne-4.2.2/CHANGELOG.txt
--- python-daphne-4.2.1/CHANGELOG.txt	2025-07-02 15:55:31.000000000 +0300
+++ python-daphne-4.2.2/CHANGELOG.txt	2026-06-03 13:51:16.000000000 +0300
@@ -1,3 +1,34 @@
+4.2.2 (2026-06-03)
+------------------
+
+* Fixed a denial of service vulnerability via unbounded WebSocket message sizes.
+  Daphne previously passed no message or frame size limits to autobahn,
+  whose defaults are unbounded. This allowed an unauthenticated client
+  to exhaust server memory by sending a very large WebSocket
+  messages/frames (CVE-2026-44545).
+
+  Both limits now default to 1 MiB and can be configured via the new
+  ``--websocket-max-message-size`` and ``--websocket-max-frame-size`` CLI
+  flags (or the matching ``Server`` constructor arguments). Pass ``0`` to
+  restore the previous unlimited behaviour.
+  
+  Thanks to ParkHyunWoo for the report.
+
+* Fixed a header injection vulnerability on the WebSocket upgrade path
+  (CVE-2026-44546).
+
+  Header values containing ``\x0b``, ``\x0c``, ``\x1c``, ``\x1d``, ``\x1e``,
+  or ``\x85`` were parsed as a single header by Twisted but split into
+  multiple headers by autobahn during the WebSocket handshake. An attacker
+  could exploit this parser differential to smuggle additional headers
+  (e.g. authentication tokens, ``X-Forwarded-For``, ``Origin``,
+  ``Daphne-Root-Path``) into the ASGI scope passed to the application.
+
+  Daphne now rejects requests carrying these bytes in any header value with
+  a 400 Bad Request response, as required by RFC 9110 §5.5.
+
+  Thanks to Rene Henningsen for the report.
+
 4.2.1 (2025-07-02)
 ------------------
 
diff -Nru python-daphne-4.2.1/daphne/cli.py python-daphne-4.2.2/daphne/cli.py
--- python-daphne-4.2.1/daphne/cli.py	2025-07-02 15:55:31.000000000 +0300
+++ python-daphne-4.2.2/daphne/cli.py	2026-06-03 13:51:16.000000000 +0300
@@ -108,6 +108,22 @@
             default=30,
         )
         self.parser.add_argument(
+            "--websocket-max-message-size",
+            type=int,
+            help="Maximum size, in bytes, of an incoming WebSocket message. "
+            "0 disables the limit (not recommended; allows unauthenticated "
+            "memory exhaustion).",
+            default=1024 * 1024,
+        )
+        self.parser.add_argument(
+            "--websocket-max-frame-size",
+            type=int,
+            help="Maximum size, in bytes, of a single incoming WebSocket frame. "
+            "0 disables the limit (not recommended; allows unauthenticated "
+            "memory exhaustion).",
+            default=1024 * 1024,
+        )
+        self.parser.add_argument(
             "--application-close-timeout",
             type=int,
             help="The number of seconds an ASGI application has to exit after client disconnect before it is killed",
@@ -275,6 +291,8 @@
             websocket_timeout=args.websocket_timeout,
             websocket_connect_timeout=args.websocket_connect_timeout,
             websocket_handshake_timeout=args.websocket_connect_timeout,
+            websocket_max_message_size=args.websocket_max_message_size,
+            websocket_max_frame_size=args.websocket_max_frame_size,
             application_close_timeout=args.application_close_timeout,
             action_logger=(
                 AccessLogGenerator(access_log_stream) if access_log_stream else None
diff -Nru python-daphne-4.2.1/daphne/http_protocol.py python-daphne-4.2.2/daphne/http_protocol.py
--- python-daphne-4.2.1/daphne/http_protocol.py	2025-07-02 15:55:31.000000000 +0300
+++ python-daphne-4.2.2/daphne/http_protocol.py	2026-06-03 13:51:16.000000000 +0300
@@ -9,7 +9,7 @@
 from twisted.web import http
 from zope.interface import implementer
 
-from .utils import HEADER_NAME_RE, parse_x_forwarded_for
+from .utils import HEADER_NAME_RE, INVALID_HEADER_VALUE_BYTES, parse_x_forwarded_for
 
 logger = logging.getLogger(__name__)
 
@@ -70,11 +70,17 @@
         try:
             self.request_start = time.time()
 
-            # Validate header names.
-            for name, _ in self.requestHeaders.getAllRawHeaders():
+            # Validate header names and values.
+            for name, values in self.requestHeaders.getAllRawHeaders():
                 if not HEADER_NAME_RE.fullmatch(name):
                     self.basic_error(400, b"Bad Request", "Invalid header name")
                     return
+                for value in values:
+                    if INVALID_HEADER_VALUE_BYTES.intersection(value):
+                        self.basic_error(
+                            400, b"Bad Request", "Invalid header value"
+                        )
+                        return
 
             # Get upgrade header
             upgrade_header = None
diff -Nru python-daphne-4.2.1/daphne/__init__.py python-daphne-4.2.2/daphne/__init__.py
--- python-daphne-4.2.1/daphne/__init__.py	2025-07-02 15:55:31.000000000 +0300
+++ python-daphne-4.2.2/daphne/__init__.py	2026-06-03 13:51:16.000000000 +0300
@@ -1,6 +1,6 @@
 import sys
 
-__version__ = "4.2.1"
+__version__ = "4.2.2"
 
 
 # Windows on Python 3.8+ uses ProactorEventLoop, which is not compatible with
diff -Nru python-daphne-4.2.1/daphne/server.py python-daphne-4.2.2/daphne/server.py
--- python-daphne-4.2.1/daphne/server.py	2025-07-02 15:55:31.000000000 +0300
+++ python-daphne-4.2.2/daphne/server.py	2026-06-03 13:51:16.000000000 +0300
@@ -63,6 +63,8 @@
         proxy_forwarded_proto_header=None,
         verbosity=1,
         websocket_handshake_timeout=5,
+        websocket_max_message_size=1024 * 1024,
+        websocket_max_frame_size=1024 * 1024,
         application_close_timeout=10,
         ready_callable=None,
         server_name="daphne",
@@ -83,6 +85,8 @@
         self.websocket_timeout = websocket_timeout
         self.websocket_connect_timeout = websocket_connect_timeout
         self.websocket_handshake_timeout = websocket_handshake_timeout
+        self.websocket_max_message_size = websocket_max_message_size
+        self.websocket_max_frame_size = websocket_max_frame_size
         self.application_close_timeout = application_close_timeout
         self.root_path = root_path
         self.verbosity = verbosity
@@ -104,6 +108,8 @@
             autoPingTimeout=self.ping_timeout,
             allowNullOrigin=True,
             openHandshakeTimeout=self.websocket_handshake_timeout,
+            maxMessagePayloadSize=self.websocket_max_message_size,
+            maxFramePayloadSize=self.websocket_max_frame_size,
         )
         if self.verbosity <= 1:
             # Redirect the Twisted log to nowhere
diff -Nru python-daphne-4.2.1/daphne/testing.py python-daphne-4.2.2/daphne/testing.py
--- python-daphne-4.2.1/daphne/testing.py	2025-07-02 15:55:31.000000000 +0300
+++ python-daphne-4.2.2/daphne/testing.py	2026-06-03 13:51:16.000000000 +0300
@@ -18,12 +18,21 @@
     startup_timeout = 2
 
     def __init__(
-        self, xff=False, http_timeout=None, request_buffer_size=None, *, application
+        self,
+        xff=False,
+        http_timeout=None,
+        request_buffer_size=None,
+        websocket_max_message_size=None,
+        websocket_max_frame_size=None,
+        *,
+        application,
     ):
         self.xff = xff
         self.http_timeout = http_timeout
         self.host = "127.0.0.1"
         self.request_buffer_size = request_buffer_size
+        self.websocket_max_message_size = websocket_max_message_size
+        self.websocket_max_frame_size = websocket_max_frame_size
         self.application = application
 
     def get_application(self):
@@ -41,6 +50,10 @@
             kwargs["proxy_forwarded_proto_header"] = "X-Forwarded-Proto"
         if self.http_timeout:
             kwargs["http_timeout"] = self.http_timeout
+        if self.websocket_max_message_size is not None:
+            kwargs["websocket_max_message_size"] = self.websocket_max_message_size
+        if self.websocket_max_frame_size is not None:
+            kwargs["websocket_max_frame_size"] = self.websocket_max_frame_size
         # Start up process
         self.process = DaphneProcess(
             host=self.host,
diff -Nru python-daphne-4.2.1/daphne/utils.py python-daphne-4.2.2/daphne/utils.py
--- python-daphne-4.2.1/daphne/utils.py	2025-07-02 15:55:31.000000000 +0300
+++ python-daphne-4.2.2/daphne/utils.py	2026-06-03 13:51:16.000000000 +0300
@@ -7,6 +7,9 @@
 # https://github.com/python-hyper/h11/blob/a2c68948accadc3876dffcf979d98002e4a4ed27/h11/_abnf.py#L10-L21
 HEADER_NAME_RE = re.compile(rb"[-!#$%&'*+.^_`|~0-9a-zA-Z]+")
 
+# Disallowed in field-value per RFC 9110 §5.5.
+INVALID_HEADER_VALUE_BYTES = frozenset(b"\x0b\x0c\x1c\x1d\x1e\x85")
+
 
 def import_by_path(path):
     """
diff -Nru python-daphne-4.2.1/debian/changelog python-daphne-4.2.2/debian/changelog
--- python-daphne-4.2.1/debian/changelog	2026-01-04 20:13:09.000000000 +0200
+++ python-daphne-4.2.2/debian/changelog	2026-06-22 22:44:26.000000000 +0300
@@ -1,3 +1,13 @@
+python-daphne (4.2.2-0.1) unstable; urgency=medium
+
+  * Non-maintainer upload.
+  * New upstream release.
+    - CVE-2026-44545: DoS via unbounded WebSocket message sizes
+    - CVE-2026-44546: Header injection on WebSocket upgrade path
+    (Closes: #1138864)
+
+ -- Adrian Bunk <[email protected]>  Mon, 22 Jun 2026 22:44:26 +0300
+
 python-daphne (4.2.1-2) unstable; urgency=medium
 
   * Team upload.
diff -Nru python-daphne-4.2.1/.github/workflows/tests.yml python-daphne-4.2.2/.github/workflows/tests.yml
--- python-daphne-4.2.1/.github/workflows/tests.yml	2025-07-02 15:55:31.000000000 +0300
+++ python-daphne-4.2.2/.github/workflows/tests.yml	2026-06-03 13:51:16.000000000 +0300
@@ -27,10 +27,10 @@
         - "3.13"
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
 
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v5
+        uses: actions/setup-python@v6
         with:
           python-version: ${{ matrix.python-version }}
 
diff -Nru python-daphne-4.2.1/.gitignore python-daphne-4.2.2/.gitignore
--- python-daphne-4.2.1/.gitignore	2025-07-02 15:55:31.000000000 +0300
+++ python-daphne-4.2.2/.gitignore	2026-06-03 13:51:16.000000000 +0300
@@ -14,3 +14,4 @@
 .pytest_cache/
 .vscode
 .coverage
+.DS_Store
diff -Nru python-daphne-4.2.1/.pre-commit-config.yaml python-daphne-4.2.2/.pre-commit-config.yaml
--- python-daphne-4.2.1/.pre-commit-config.yaml	2025-07-02 15:55:31.000000000 +0300
+++ python-daphne-4.2.2/.pre-commit-config.yaml	2026-06-03 13:51:16.000000000 +0300
@@ -1,6 +1,6 @@
 repos:
   - repo: https://github.com/asottile/pyupgrade
-    rev: v3.19.1
+    rev: v3.20.0
     hooks:
       - id: pyupgrade
         args: [--py39-plus]
@@ -14,7 +14,7 @@
     hooks:
       - id: isort
   - repo: https://github.com/PyCQA/flake8
-    rev: 7.2.0
+    rev: 7.3.0
     hooks:
       - id: flake8
         additional_dependencies:
diff -Nru python-daphne-4.2.1/tests/test_websocket.py python-daphne-4.2.2/tests/test_websocket.py
--- python-daphne-4.2.1/tests/test_websocket.py	2025-07-02 15:55:31.000000000 +0300
+++ python-daphne-4.2.2/tests/test_websocket.py	2026-06-03 13:51:16.000000000 +0300
@@ -267,6 +267,75 @@
                 "bytes": b"what is here? \xe2",
             }
 
+    def assert_oversized_frame_rejected(self, test_app):
+        """
+        Sends a 16-byte text frame and asserts the application sees only
+        connect + disconnect — i.e. autobahn dropped the connection (its
+        default failByDrop behaviour) before dispatching the payload.
+        """
+        test_app.add_send_messages([{"type": "websocket.accept"}])
+        sock, _ = self.websocket_handshake(test_app)
+        _, messages = test_app.get_received()
+        self.assert_valid_websocket_connect_message(messages[0])
+        self.websocket_send_frame(sock, "x" * 16)
+        deadline = time.time() + 2
+        final_messages = []
+        while time.time() < deadline:
+            _, final_messages = test_app.get_received()
+            if any(m["type"] == "websocket.disconnect" for m in final_messages):
+                break
+            time.sleep(0.05)
+        try:
+            sock.close()
+        except OSError:
+            pass
+        types = [m["type"] for m in final_messages]
+        self.assertEqual(
+            types,
+            ["websocket.connect", "websocket.disconnect"],
+            "Oversized frame should not have been delivered to the "
+            f"application, but got: {types}",
+        )
+
+    def test_websocket_max_message_size(self):
+        """
+        Tests that an incoming WebSocket message exceeding
+        ``websocket_max_message_size`` is rejected by autobahn before it
+        reaches the application.
+        """
+        # 16-byte frame > 8-byte message limit.
+        with DaphneTestingInstance(websocket_max_message_size=8) as test_app:
+            self.assert_oversized_frame_rejected(test_app)
+
+    def test_websocket_max_frame_size(self):
+        """
+        Tests that an incoming WebSocket frame exceeding
+        ``websocket_max_frame_size`` is rejected by autobahn before it
+        reaches the application, independently of the message size limit.
+        """
+        # Large message limit, so the frame size limit is what trips.
+        with DaphneTestingInstance(
+            websocket_max_frame_size=8,
+            websocket_max_message_size=1024 * 1024,
+        ) as test_app:
+            self.assert_oversized_frame_rejected(test_app)
+
+    def test_websocket_max_message_size_allows_under_limit(self):
+        """
+        Tests that messages under ``websocket_max_message_size`` are
+        delivered to the application unchanged.
+        """
+        with DaphneTestingInstance(websocket_max_message_size=64) as test_app:
+            test_app.add_send_messages([{"type": "websocket.accept"}])
+            sock, _ = self.websocket_handshake(test_app)
+            _, messages = test_app.get_received()
+            self.assert_valid_websocket_connect_message(messages[0])
+            test_app.add_send_messages(
+                [{"type": "websocket.send", "text": "ack"}]
+            )
+            self.websocket_send_frame(sock, "x" * 16)
+            assert self.websocket_receive_frame(sock) == "ack"
+
     def test_http_timeout(self):
         """
         Tests that the HTTP timeout doesn't kick in for WebSockets
@@ -306,6 +375,70 @@
                 )
 
 
+class TestHeaderValueInjection(DaphneTestCase):
+    """
+    Twisted's bytes HTTP parser does not treat \\x0b, \\x0c, \\x1c, \\x1d, \\x1e
+    or \\x85 as line separators, but autobahn's WebSocket handshake parser
+    decodes to str and calls splitlines(), which does. Without rejection at
+    the Daphne edge, an attacker can smuggle additional headers into the
+    WebSocket ASGI scope through a single header value. Reject these bytes
+    on both paths so values can never reach a downstream str-based parser.
+    """
+
+    INVALID_BYTES = (
+        b"\x0b",  # vertical tab
+        b"\x0c",  # form feed
+        b"\x1c",  # file separator
+        b"\x1d",  # group separator
+        b"\x1e",  # record separator
+        b"\x85",  # NEL
+    )
+
+    def _websocket_upgrade_request(self, value):
+        return (
+            b"GET /ws HTTP/1.1\r\n"
+            b"Host: example.com\r\n"
+            b"Upgrade: websocket\r\n"
+            b"Connection: Upgrade\r\n"
+            b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
+            b"Sec-WebSocket-Version: 13\r\n"
+            b"X-Padding: " + value + b"\r\n"
+            b"\r\n"
+        )
+
+    def _http_request(self, value):
+        return (
+            b"GET / HTTP/1.1\r\n"
+            b"Host: example.com\r\n"
+            b"X-Padding: " + value + b"\r\n"
+            b"\r\n"
+        )
+
+    def test_websocket_upgrade_rejects_smuggled_headers(self):
+        for byte in self.INVALID_BYTES:
+            with self.subTest(byte=byte):
+                value = b"innocent" + byte + b"X-Secret-Auth: admin-token"
+                response = self.run_daphne_raw(
+                    self._websocket_upgrade_request(value)
+                )
+                self.assertTrue(
+                    response.startswith(b"HTTP/1.1 400"),
+                    f"expected 400 for byte {byte!r}, got {response[:80]!r}",
+                )
+                # Confirm the smuggled header didn't slip past validation.
+                self.assertNotIn(b"X-Secret-Auth", response)
+
+    def test_http_request_rejects_invalid_header_value_bytes(self):
+        for byte in self.INVALID_BYTES:
+            with self.subTest(byte=byte):
+                value = b"innocent" + byte + b"injected"
+                response = self.run_daphne_raw(self._http_request(value))
+                self.assertTrue(
+                    response.startswith(b"HTTP/1.1 400"),
+                    f"expected 400 for byte {byte!r}, got {response[:80]!r}",
+                )
+
+
 async def cancelling_application(scope, receive, send):
     import asyncio
 

Reply via email to