diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..d60ef28 --- /dev/null +++ b/.flake8 @@ -0,0 +1,11 @@ +[flake8] +exclude = + .venv, + .tox, + docs, + testproject, + js_client, + .eggs + +extend-ignore = E123, E128, E266, E402, W503, E731, W601, B036 +max-line-length = 120 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e5903e0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9792252..68b91f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,10 @@ on: branches: - main pull_request: + workflow_dispatch: + +permissions: + contents: read jobs: tests: @@ -16,23 +20,24 @@ jobs: - ubuntu - windows python-version: - - "3.7" - - "3.8" - "3.9" - "3.10" + - "3.11" + - "3.12" + - "3.13" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel - python -m pip install --upgrade tox tox-py + python -m pip install --upgrade tox - name: Run tox targets for ${{ matrix.python-version }} - run: tox --py current + run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) diff --git a/.gitignore b/.gitignore index 20cb4bd..0616f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ test_consumer* .python-version .pytest_cache/ .vscode +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c47198d..c315a04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,21 +1,23 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.37.3 + rev: v3.19.1 hooks: - id: pyupgrade - args: [--py36-plus] + args: [--py39-plus] - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 25.1.0 hooks: - id: black language_version: python3 - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: 6.0.1 hooks: - id: isort - repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 + rev: 7.2.0 hooks: - id: flake8 additional_dependencies: - flake8-bugbear +ci: + autoupdate_schedule: quarterly diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 9416110..40e6e3f 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,8 +1,60 @@ -4.0.0b1 (2022-08-25) --------------------- +4.2.0 (to be released) +------------------ -This is a beta release to allow testing compatibility with the upcoming Channels -4.0. +* Added support for Python 3.13. + +* Drop support for EOL Python 3.8. + +* Removed unused pytest-runner + +* Fixed sdist file to ensure it includes all tests + +4.1.2 (2024-04-11) +------------------ + +* Fixed a setuptools configuration error in 4.1.1. + +4.1.1 (2024-04-10) +------------------ + +* Fixed a twisted.plugin packaging error in 4.1.0. + + Thanks to sdc50. + +4.1.0 (2024-02-10) +------------------ + +* Added support for Python 3.12. + +* Dropped support for EOL Python 3.7. + +* Handled root path for websocket scopes. + +* Validate HTTP header names as per RFC 9110. + +4.0.0 (2022-10-07) +------------------ + +Major versioning targeting use with Channels 4.0 and beyond. Except where +noted should remain usable with Channels v3 projects, but updating Channels to the latest version is recommended. + +* Added a ``runserver`` command to run an ASGI Django development server. + + Added ``"daphne"`` to the ``INSTALLED_APPS`` setting, before + ``"django.contrib.staticfiles"`` to enable: + + INSTALLED_APPS = [ + "daphne", + ... + ] + + This replaces the Channels implementation of ``runserver``, which is removed + in Channels 4.0. + +* Made the ``DaphneProcess`` tests helper class compatible with the ``spawn`` + process start method, which is used on macOS and Windows. + + Note that requires Channels v4 if using with ``ChannelsLiveServerTestCase``. * Dropped support for Python 3.6. @@ -29,15 +81,7 @@ This is a beta release to allow testing compatibility with the upcoming Channels Set e.g. ``ASGI_THREADS=4 daphne ...`` when running to limit the number of workers. -* Added a ``runserver`` command to run an ASGI Django development server. - - Added ``"daphne"`` to the ``INSTALLED_APPS`` setting, before - ``"django.contrib.staticfiles"`` to enable: - - INSTALLED_APPS = [ - "daphne", - ... - ] +* Removed deprecated ``--ws_protocols`` CLI option. 3.0.2 (2021-04-07) ------------------ diff --git a/README.rst b/README.rst index 70c4ba5..f7b8e32 100644 --- a/README.rst +++ b/README.rst @@ -54,7 +54,7 @@ Daphne supports terminating HTTP/2 connections natively. You'll need to do a couple of things to get it working, though. First, you need to make sure you install the Twisted ``http2`` and ``tls`` extras:: - pip install -U 'Twisted[tls,http2]' + pip install -U "Twisted[tls,http2]" Next, because all current browsers only support HTTP/2 when using TLS, you will need to start Daphne with TLS turned on, which can be done using the Twisted endpoint syntax:: @@ -108,7 +108,7 @@ should start with a slash, but not end with one; for example:: Python Support -------------- -Daphne requires Python 3.7 or later. +Daphne requires Python 3.9 or later. Contributing diff --git a/daphne/__init__.py b/daphne/__init__.py index 6ac6f94..2a21298 100755 --- a/daphne/__init__.py +++ b/daphne/__init__.py @@ -1,6 +1,6 @@ import sys -__version__ = "4.0.0b1" +__version__ = "4.1.3" # Windows on Python 3.8+ uses ProactorEventLoop, which is not compatible with diff --git a/daphne/cli.py b/daphne/cli.py index accafe1..a036821 100755 --- a/daphne/cli.py +++ b/daphne/cli.py @@ -113,13 +113,6 @@ class CommandLineInterface: help="The number of seconds an ASGI application has to exit after client disconnect before it is killed", default=10, ) - self.parser.add_argument( - "--ws-protocol", - nargs="*", - dest="ws_protocols", - help="The WebSocket protocols you wish to support", - default=None, - ) self.parser.add_argument( "--root-path", dest="root_path", @@ -277,17 +270,16 @@ class CommandLineInterface: websocket_connect_timeout=args.websocket_connect_timeout, websocket_handshake_timeout=args.websocket_connect_timeout, application_close_timeout=args.application_close_timeout, - action_logger=AccessLogGenerator(access_log_stream) - if access_log_stream - else None, - ws_protocols=args.ws_protocols, + action_logger=( + AccessLogGenerator(access_log_stream) if access_log_stream else None + ), root_path=args.root_path, verbosity=args.verbosity, proxy_forwarded_address_header=self._get_forwarded_host(args=args), proxy_forwarded_port_header=self._get_forwarded_port(args=args), - proxy_forwarded_proto_header="X-Forwarded-Proto" - if args.proxy_headers - else None, + proxy_forwarded_proto_header=( + "X-Forwarded-Proto" if args.proxy_headers else None + ), server_name=args.server_name, ) self.server.run() diff --git a/daphne/http_protocol.py b/daphne/http_protocol.py index f0657fd..e9eba96 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 parse_x_forwarded_for +from .utils import HEADER_NAME_RE, parse_x_forwarded_for logger = logging.getLogger(__name__) @@ -69,6 +69,13 @@ class WebRequest(http.Request): def process(self): try: self.request_start = time.time() + + # Validate header names. + for name, _ in self.requestHeaders.getAllRawHeaders(): + if not HEADER_NAME_RE.fullmatch(name): + self.basic_error(400, b"Bad Request", "Invalid header name") + return + # Get upgrade header upgrade_header = None if self.requestHeaders.hasHeader(b"Upgrade"): @@ -279,9 +286,11 @@ class WebRequest(http.Request): "path": uri, "status": self.code, "method": self.method.decode("ascii", "replace"), - "client": "%s:%s" % tuple(self.client_addr) - if self.client_addr - else None, + "client": ( + "%s:%s" % tuple(self.client_addr) + if self.client_addr + else None + ), "time_taken": self.duration(), "size": self.sentLength, }, diff --git a/daphne/management/commands/runserver.py b/daphne/management/commands/runserver.py index b2fd2ee..7e67e07 100644 --- a/daphne/management/commands/runserver.py +++ b/daphne/management/commands/runserver.py @@ -73,6 +73,18 @@ class Command(RunserverCommand): "seconds (default: 5)" ), ) + parser.add_argument( + "--nostatic", + action="store_false", + dest="use_static_handler", + help="Tells Django to NOT automatically serve static files at STATIC_URL.", + ) + parser.add_argument( + "--insecure", + action="store_true", + dest="insecure_serving", + help="Allows serving static files even if DEBUG is False.", + ) def handle(self, *args, **options): self.http_timeout = options.get("http_timeout", None) diff --git a/daphne/server.py b/daphne/server.py index e5728b3..a6d3819 100755 --- a/daphne/server.py +++ b/daphne/server.py @@ -21,6 +21,7 @@ if current_reactor is not None: + "you can fix this warning by importing daphne.server early in your codebase or " + "finding the package that imports Twisted and importing it later on.", UserWarning, + stacklevel=2, ) del sys.modules["twisted.internet.reactor"] asyncioreactor.install(twisted_loop) @@ -65,8 +66,6 @@ class Server: application_close_timeout=10, ready_callable=None, server_name="daphne", - # Deprecated and does not work, remove in version 2.2 - ws_protocols=None, ): self.application = application self.endpoints = endpoints or [] diff --git a/daphne/testing.py b/daphne/testing.py index e2c7200..9120327 100644 --- a/daphne/testing.py +++ b/daphne/testing.py @@ -26,6 +26,9 @@ class BaseDaphneTestingInstance: self.request_buffer_size = request_buffer_size self.application = application + def get_application(self): + return self.application + def __enter__(self): # Option Daphne features kwargs = {} @@ -41,7 +44,7 @@ class BaseDaphneTestingInstance: # Start up process self.process = DaphneProcess( host=self.host, - application=self.application, + get_application=self.get_application, kwargs=kwargs, setup=self.process_setup, teardown=self.process_teardown, @@ -123,14 +126,16 @@ class DaphneProcess(multiprocessing.Process): port it ends up listening on back to the parent process. """ - def __init__(self, host, application, kwargs=None, setup=None, teardown=None): + def __init__( + self, host, get_application, kwargs=None, setup=None, teardown=None, port=None + ): super().__init__() self.host = host - self.application = application + self.get_application = get_application self.kwargs = kwargs or {} - self.setup = setup or (lambda: None) - self.teardown = teardown or (lambda: None) - self.port = multiprocessing.Value("i") + self.setup = setup + self.teardown = teardown + self.port = multiprocessing.Value("i", port if port is not None else 0) self.ready = multiprocessing.Event() self.errors = multiprocessing.Queue() @@ -146,23 +151,29 @@ class DaphneProcess(multiprocessing.Process): from .endpoints import build_endpoint_description_strings from .server import Server + application = self.get_application() + try: # Create the server class - endpoints = build_endpoint_description_strings(host=self.host, port=0) + endpoints = build_endpoint_description_strings( + host=self.host, port=self.port.value + ) self.server = Server( - application=self.application, + application=application, endpoints=endpoints, signal_handlers=False, - **self.kwargs + **self.kwargs, ) # Set up a poller to look for the port reactor.callLater(0.1, self.resolve_port) # Run with setup/teardown - self.setup() + if self.setup is not None: + self.setup() try: self.server.run() finally: - self.teardown() + if self.teardown is not None: + self.teardown() except BaseException as e: # Put the error on our queue so the parent gets it self.errors.put((e, traceback.format_exc())) diff --git a/daphne/utils.py b/daphne/utils.py index 81f1f9d..0699314 100644 --- a/daphne/utils.py +++ b/daphne/utils.py @@ -1,7 +1,12 @@ import importlib +import re from twisted.web.http_headers import Headers +# Header name regex as per h11. +# https://github.com/python-hyper/h11/blob/a2c68948accadc3876dffcf979d98002e4a4ed27/h11/_abnf.py#L10-L21 +HEADER_NAME_RE = re.compile(rb"[-!#$%&'*+.^_`|~0-9a-zA-Z]+") + def import_by_path(path): """ diff --git a/daphne/ws_protocol.py b/daphne/ws_protocol.py index 975b1a9..b1e29c3 100755 --- a/daphne/ws_protocol.py +++ b/daphne/ws_protocol.py @@ -31,17 +31,21 @@ class WebSocketProtocol(WebSocketServerProtocol): self.server.protocol_connected(self) self.request = request self.protocol_to_accept = None + self.root_path = self.server.root_path self.socket_opened = time.time() self.last_ping = time.time() try: - # Sanitize and decode headers + # Sanitize and decode headers, potentially extracting root path self.clean_headers = [] for name, value in request.headers.items(): name = name.encode("ascii") # Prevent CVE-2015-0219 if b"_" in name: continue - self.clean_headers.append((name.lower(), value.encode("latin1"))) + if name.lower() == b"daphne-root-path": + self.root_path = unquote(value) + else: + self.clean_headers.append((name.lower(), value.encode("latin1"))) # Get client address if possible peer = self.transport.getPeer() host = self.transport.getHost() @@ -76,6 +80,7 @@ class WebSocketProtocol(WebSocketServerProtocol): "type": "websocket", "path": unquote(self.path.decode("ascii")), "raw_path": self.path, + "root_path": self.root_path, "headers": self.clean_headers, "query_string": self._raw_query_string, # Passed by HTTP protocol "client": self.client_addr, @@ -110,9 +115,9 @@ class WebSocketProtocol(WebSocketServerProtocol): "connecting", { "path": self.request.path, - "client": "%s:%s" % tuple(self.client_addr) - if self.client_addr - else None, + "client": ( + "%s:%s" % tuple(self.client_addr) if self.client_addr else None + ), }, ) @@ -133,9 +138,9 @@ class WebSocketProtocol(WebSocketServerProtocol): "connected", { "path": self.request.path, - "client": "%s:%s" % tuple(self.client_addr) - if self.client_addr - else None, + "client": ( + "%s:%s" % tuple(self.client_addr) if self.client_addr else None + ), }, ) @@ -170,9 +175,9 @@ class WebSocketProtocol(WebSocketServerProtocol): "disconnected", { "path": self.request.path, - "client": "%s:%s" % tuple(self.client_addr) - if self.client_addr - else None, + "client": ( + "%s:%s" % tuple(self.client_addr) if self.client_addr else None + ), }, ) @@ -237,9 +242,9 @@ class WebSocketProtocol(WebSocketServerProtocol): "rejected", { "path": self.request.path, - "client": "%s:%s" % tuple(self.client_addr) - if self.client_addr - else None, + "client": ( + "%s:%s" % tuple(self.client_addr) if self.client_addr else None + ), }, ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7a2e796 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,81 @@ +[project] +name = "daphne" +dynamic = ["version"] +description = "Django ASGI (HTTP/WebSocket) server" +requires-python = ">=3.9" +authors = [ + { name = "Django Software Foundation", email = "foundation@djangoproject.com" }, +] + +license = { text = "BSD" } +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP", +] + +dependencies = ["asgiref>=3.5.2,<4", "autobahn>=22.4.2", "twisted[tls]>=22.4"] + +[project.optional-dependencies] +tests = [ + "django", + "hypothesis", + "pytest", + "pytest-asyncio", + "pytest-cov", + "black", + "tox", + "flake8", + "flake8-bugbear", + "mypy", +] + +[project.urls] +homepage = "https://github.com/django/daphne" +documentation = "https://channels.readthedocs.io" +repository = "https://github.com/django/daphne.git" +changelog = "https://github.com/django/daphne/blob/main/CHANGELOG.txt" +issues = "https://github.com/django/daphne/issues" + +[project.scripts] +daphne = "daphne.cli:CommandLineInterface.entrypoint" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["daphne"] + +[tool.setuptools.dynamic] +version = { attr = "daphne.__version__" } +readme = { file = "README.rst", content-type = "text/x-rst" } + +[tool.isort] +profile = "black" + +[tool.pytest] +testpaths = ["tests"] +asyncio_mode = "strict" +filterwarnings = ["ignore::pytest.PytestDeprecationWarning"] + +[tool.coverage.run] +omit = ["tests/*"] +concurrency = ["multiprocessing"] + +[tool.coverage.report] +show_missing = "true" +skip_covered = "true" + +[tool.coverage.html] +directory = "reports/coverage_html_report" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index e7c19e5..0000000 --- a/setup.cfg +++ /dev/null @@ -1,11 +0,0 @@ -[flake8] -exclude = venv/*,tox/*,docs/*,testproject/*,js_client/*,.eggs/* -extend-ignore = E123, E128, E266, E402, W503, E731, W601 -max-line-length = 120 - -[isort] -profile = black - -[tool:pytest] -testpaths = tests -asyncio_mode = strict diff --git a/setup.py b/setup.py deleted file mode 100755 index ae19f5d..0000000 --- a/setup.py +++ /dev/null @@ -1,46 +0,0 @@ -import os - -from setuptools import find_packages, setup - -from daphne import __version__ - -# We use the README as the long_description -readme_path = os.path.join(os.path.dirname(__file__), "README.rst") -with open(readme_path) as fp: - long_description = fp.read() - -setup( - name="daphne", - version=__version__, - url="https://github.com/django/daphne", - author="Django Software Foundation", - author_email="foundation@djangoproject.com", - description="Django ASGI (HTTP/WebSocket) server", - long_description=long_description, - license="BSD", - zip_safe=False, - package_dir={"twisted": "daphne/twisted"}, - packages=find_packages() + ["twisted.plugins"], - include_package_data=True, - install_requires=["twisted[tls]>=22.4", "autobahn>=22.4.2", "asgiref>=3.5.2,<4"], - python_requires=">=3.7", - setup_requires=["pytest-runner"], - extras_require={"tests": ["hypothesis", "pytest", "pytest-asyncio", "django"]}, - entry_points={ - "console_scripts": ["daphne = daphne.cli:CommandLineInterface.entrypoint"] - }, - classifiers=[ - "Development Status :: 4 - Beta", - "Environment :: Web Environment", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Topic :: Internet :: WWW/HTTP", - ], -) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 52f6dd1..f0fe376 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -304,9 +304,21 @@ class TestHTTPRequest(DaphneTestCase): response = self.run_daphne_raw( b"GET /\xc3\xa4\xc3\xb6\xc3\xbc HTTP/1.0\r\n\r\n" ) - self.assertTrue(response.startswith(b"HTTP/1.0 400 Bad Request")) + self.assertTrue(b"400 Bad Request" in response) # Bad querystring response = self.run_daphne_raw( b"GET /?\xc3\xa4\xc3\xb6\xc3\xbc HTTP/1.0\r\n\r\n" ) - self.assertTrue(response.startswith(b"HTTP/1.0 400 Bad Request")) + self.assertTrue(b"400 Bad Request" in response) + + def test_invalid_header_name(self): + """ + Tests that requests with invalid header names fail. + """ + # Test cases follow those used by h11 + # https://github.com/python-hyper/h11/blob/a2c68948accadc3876dffcf979d98002e4a4ed27/h11/tests/test_headers.py#L24-L35 + for header_name in [b"foo bar", b"foo\x00bar", b"foo\xffbar", b"foo\x01bar"]: + response = self.run_daphne_raw( + f"GET / HTTP/1.0\r\n{header_name}: baz\r\n\r\n".encode("ascii") + ) + self.assertTrue(b"400 Bad Request" in response) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 9b67aa1..851143c 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -192,6 +192,30 @@ class TestWebsocket(DaphneTestCase): self.assertEqual(scope["path"], "/foo/bar") self.assertEqual(scope["raw_path"], b"/foo%2Fbar") + @given(daphne_path=http_strategies.http_path()) + @settings(max_examples=5, deadline=2000) + def test_root_path(self, *, daphne_path): + """ + Tests root_path handling. + """ + headers = [("Daphne-Root-Path", parse.quote(daphne_path))] + with DaphneTestingInstance() as test_app: + test_app.add_send_messages([{"type": "websocket.accept"}]) + self.websocket_handshake( + test_app, + path="/", + headers=headers, + ) + # Validate the scope and messages we got + scope, _ = test_app.get_received() + + # Daphne-Root-Path is not included in the returned 'headers' section. + self.assertNotIn( + "daphne-root-path", (header[0].lower() for header in scope["headers"]) + ) + # And what we're looking for, root_path being set. + self.assertEqual(scope["root_path"], daphne_path) + def test_text_frames(self): """ Tests we can send and receive text frames. @@ -288,7 +312,7 @@ async def cancelling_application(scope, receive, send): from twisted.internet import reactor # Stop the server after a short delay so that the teardown is run. - reactor.callLater(2, lambda: reactor.stop()) + reactor.callLater(2, reactor.stop) await send({"type": "websocket.accept"}) raise asyncio.CancelledError() diff --git a/tox.ini b/tox.ini index 876ff99..e3bae91 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = - py{37,38,39,310} + py{39,310,311,312,313} + [testenv] -usedevelop = true extras = tests commands = pytest -v {posargs}