Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: [email protected], [email protected]
Control: affects -1 + src:python-daphne
User: [email protected]
Usertags: pu

  * CVE-2026-44545: DoS via unbounded WebSocket message sizes
  * CVE-2026-44546: Header injection on WebSocket upgrade path
  * (Closes: #1138864)
diffstat for python-daphne-4.1.2 python-daphne-4.1.2

 changelog                                                               |   10 
 patches/0001-Fixed-CVE-2026-44546-Prevent-header-injection-on-Web.patch |  154 
+++++++
 patches/0002-Fixed-CVE-2026-44545-Limit-WebSocket-sizes-in-autoba.patch |  212 
++++++++++
 patches/series                                                          |    2 
 4 files changed, 378 insertions(+)

diff -Nru python-daphne-4.1.2/debian/changelog 
python-daphne-4.1.2/debian/changelog
--- python-daphne-4.1.2/debian/changelog        2024-08-18 20:46:43.000000000 
+0300
+++ python-daphne-4.1.2/debian/changelog        2026-07-03 20:38:15.000000000 
+0300
@@ -1,3 +1,13 @@
+python-daphne (4.1.2-2+deb13u1) trixie; urgency=medium
+
+  * Non-maintainer upload.
+  * CVE-2026-44545: DoS via unbounded WebSocket message sizes
+  * CVE-2026-44546: Header injection on WebSocket upgrade path
+  * (Closes: #1138864)
+
+
+ -- Adrian Bunk <[email protected]>  Fri, 03 Jul 2026 20:38:15 +0300
+
 python-daphne (4.1.2-2) unstable; urgency=medium
 
   * Team upload.
diff -Nru 
python-daphne-4.1.2/debian/patches/0001-Fixed-CVE-2026-44546-Prevent-header-injection-on-Web.patch
 
python-daphne-4.1.2/debian/patches/0001-Fixed-CVE-2026-44546-Prevent-header-injection-on-Web.patch
--- 
python-daphne-4.1.2/debian/patches/0001-Fixed-CVE-2026-44546-Prevent-header-injection-on-Web.patch
  1970-01-01 02:00:00.000000000 +0200
+++ 
python-daphne-4.1.2/debian/patches/0001-Fixed-CVE-2026-44546-Prevent-header-injection-on-Web.patch
  2026-07-03 20:36:46.000000000 +0300
@@ -0,0 +1,154 @@
+From c69f1a75efa90c423f1a93f329fd8d6814e221a3 Mon Sep 17 00:00:00 2001
+From: Carlton Gibson <[email protected]>
+Date: Wed, 6 May 2026 09:23:37 +0200
+Subject: Fixed CVE-2026-44546: Prevent header injection on WebSocket upgrade
+ path
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+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.
+---
+ daphne/http_protocol.py | 12 ++++++--
+ daphne/utils.py         |  3 ++
+ tests/test_websocket.py | 64 +++++++++++++++++++++++++++++++++++++++++
+ 3 files changed, 76 insertions(+), 3 deletions(-)
+
+diff --git a/daphne/http_protocol.py b/daphne/http_protocol.py
+index e9eba96..1319f46 100755
+--- a/daphne/http_protocol.py
++++ b/daphne/http_protocol.py
+@@ -9,7 +9,7 @@ from twisted.protocols.policies import ProtocolWrapper
+ 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 @@ class WebRequest(http.Request):
+         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 --git a/daphne/utils.py b/daphne/utils.py
+index 0699314..b634cd0 100644
+--- a/daphne/utils.py
++++ b/daphne/utils.py
+@@ -7,6 +7,9 @@ from twisted.web.http_headers import Headers
+ # 
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 --git a/tests/test_websocket.py b/tests/test_websocket.py
+index 851143c..3ffbdb8 100644
+--- a/tests/test_websocket.py
++++ b/tests/test_websocket.py
+@@ -306,6 +306,70 @@ class TestWebsocket(DaphneTestCase):
+                 )
+ 
+ 
++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
+ 
+-- 
+2.47.3
+
diff -Nru 
python-daphne-4.1.2/debian/patches/0002-Fixed-CVE-2026-44545-Limit-WebSocket-sizes-in-autoba.patch
 
python-daphne-4.1.2/debian/patches/0002-Fixed-CVE-2026-44545-Limit-WebSocket-sizes-in-autoba.patch
--- 
python-daphne-4.1.2/debian/patches/0002-Fixed-CVE-2026-44545-Limit-WebSocket-sizes-in-autoba.patch
  1970-01-01 02:00:00.000000000 +0200
+++ 
python-daphne-4.1.2/debian/patches/0002-Fixed-CVE-2026-44545-Limit-WebSocket-sizes-in-autoba.patch
  2026-07-03 20:36:46.000000000 +0300
@@ -0,0 +1,212 @@
+From 23e39f0c82ab8e7cb08ca7685a86af6dbd009097 Mon Sep 17 00:00:00 2001
+From: Carlton Gibson <[email protected]>
+Date: Wed, 6 May 2026 08:50:22 +0200
+Subject: Fixed CVE-2026-44545: Limit WebSocket sizes in autobahn config.
+
+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.
+---
+ daphne/cli.py           | 18 +++++++++++
+ daphne/server.py        |  6 ++++
+ daphne/testing.py       | 15 ++++++++-
+ tests/test_websocket.py | 69 +++++++++++++++++++++++++++++++++++++++++
+ 4 files changed, 107 insertions(+), 1 deletion(-)
+
+diff --git a/daphne/cli.py b/daphne/cli.py
+index a036821..0c88a9c 100755
+--- a/daphne/cli.py
++++ b/daphne/cli.py
+@@ -107,6 +107,22 @@ class CommandLineInterface:
+             help="The number of seconds before a WebSocket is closed if no 
response to a keepalive ping",
+             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,
+@@ -269,6 +285,8 @@ class CommandLineInterface:
+             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 --git a/daphne/server.py b/daphne/server.py
+index a6d3819..4225046 100755
+--- a/daphne/server.py
++++ b/daphne/server.py
+@@ -63,6 +63,8 @@ class Server:
+         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 @@ class Server:
+         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 @@ class Server:
+             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 --git a/daphne/testing.py b/daphne/testing.py
+index 785edf9..6a426c8 100644
+--- a/daphne/testing.py
++++ b/daphne/testing.py
+@@ -18,12 +18,21 @@ class BaseDaphneTestingInstance:
+     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 @@ class BaseDaphneTestingInstance:
+             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 --git a/tests/test_websocket.py b/tests/test_websocket.py
+index 3ffbdb8..844b14e 100644
+--- a/tests/test_websocket.py
++++ b/tests/test_websocket.py
+@@ -267,6 +267,75 @@ class TestWebsocket(DaphneTestCase):
+                 "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
+-- 
+2.47.3
+
diff -Nru python-daphne-4.1.2/debian/patches/series 
python-daphne-4.1.2/debian/patches/series
--- python-daphne-4.1.2/debian/patches/series   2024-08-18 20:46:43.000000000 
+0300
+++ python-daphne-4.1.2/debian/patches/series   2026-07-03 20:38:13.000000000 
+0300
@@ -1 +1,3 @@
 twisted-24.7.0.patch
+0001-Fixed-CVE-2026-44546-Prevent-header-injection-on-Web.patch
+0002-Fixed-CVE-2026-44545-Limit-WebSocket-sizes-in-autoba.patch

Reply via email to