--- Begin Message ---
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
--- End Message ---