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