From 4062a82ba75fcbb1839a8cb086cabfd95325c9d7 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 23 Jan 2026 00:25:50 +0100 Subject: [PATCH] eventlet: Require eventlet 0.40.3+ for security fixes Upgrade minimum eventlet version to 0.40.3 to address security vulnerabilities: - CVE-2021-21419 (Moderate 6.9): Websocket memory exhaustion via large/compressed frames (fixed in 0.31.0) - CVE-2025-58068 (Moderate 6.3): HTTP Request Smuggling via improper trailer handling (fixed in 0.40.3) Also restructure module to call monkey_patch() at import time for better patching coverage, while keeping hubs.use_hub() in the worker's patch() method since it creates OS resources that don't survive fork. Add comprehensive tests for the eventlet worker. --- gunicorn/config.py | 2 +- gunicorn/workers/geventlet.py | 26 +- pyproject.toml | 2 +- tests/workers/test_geventlet.py | 404 +++++++++++++++++++++++++++++++- 4 files changed, 423 insertions(+), 11 deletions(-) diff --git a/gunicorn/config.py b/gunicorn/config.py index a789142c..56c7df3c 100644 --- a/gunicorn/config.py +++ b/gunicorn/config.py @@ -678,7 +678,7 @@ class WorkerClass(Setting): A string referring to one of the following bundled classes: * ``sync`` - * ``eventlet`` - Requires eventlet >= 0.24.1 (or install it via + * ``eventlet`` - Requires eventlet >= 0.40.3 (or install it via ``pip install gunicorn[eventlet]``) * ``gevent`` - Requires gevent >= 23.9.0 (or install it via ``pip install gunicorn[gevent]``) diff --git a/gunicorn/workers/geventlet.py b/gunicorn/workers/geventlet.py index 087eb61e..9082a1ad 100644 --- a/gunicorn/workers/geventlet.py +++ b/gunicorn/workers/geventlet.py @@ -2,17 +2,26 @@ # This file is part of gunicorn released under the MIT license. # See the NOTICE for more information. -from functools import partial -import sys - +# NOTE: eventlet import and monkey_patch() must happen before any other imports +# to ensure all standard library modules are properly patched. try: import eventlet except ImportError: - raise RuntimeError("eventlet worker requires eventlet 0.24.1 or higher") + raise RuntimeError("eventlet worker requires eventlet 0.40.3 or higher") else: from packaging.version import parse as parse_version - if parse_version(eventlet.__version__) < parse_version('0.24.1'): - raise RuntimeError("eventlet worker requires eventlet 0.24.1 or higher") + if parse_version(eventlet.__version__) < parse_version('0.40.3'): + raise RuntimeError("eventlet worker requires eventlet 0.40.3 or higher") + +# Perform monkey patching early, before importing other modules. +# This ensures that all subsequent imports get the patched versions. +# NOTE: hubs.use_hub() must NOT be called here - it creates OS resources +# (like kqueue on macOS) that don't survive fork. It must be called in +# each worker process after fork, in the patch() method. +eventlet.monkey_patch() + +from functools import partial +import sys from eventlet import hubs, greenthread from eventlet.greenio import GreenSocket @@ -124,8 +133,11 @@ def patch_sendfile(): class EventletWorker(AsyncWorker): def patch(self): + # NOTE: eventlet.monkey_patch() is called at module import time to + # ensure all imports are properly patched. However, hubs.use_hub() + # must be called here (after fork) because it creates OS resources + # like kqueue that don't survive fork. hubs.use_hub() - eventlet.monkey_patch() patch_sendfile() def is_already_handled(self, respiter): diff --git a/pyproject.toml b/pyproject.toml index 3549632d..63ef2cd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ Changelog = "https://docs.gunicorn.org/en/stable/news.html" [project.optional-dependencies] gevent = ["gevent>=23.9.0"] -eventlet = ["eventlet>=0.24.1,!=0.36.0"] +eventlet = ["eventlet>=0.40.3"] tornado = ["tornado>=6.5.0"] gthread = [] setproctitle = ["setproctitle"] diff --git a/tests/workers/test_geventlet.py b/tests/workers/test_geventlet.py index 446f7037..0719f038 100644 --- a/tests/workers/test_geventlet.py +++ b/tests/workers/test_geventlet.py @@ -4,13 +4,413 @@ import pytest import sys +from unittest import mock + def test_import(): - + """Test that the eventlet worker module can be imported.""" try: import eventlet except AttributeError: - if (3,13) > sys.version_info >= (3, 12): + if (3, 13) > sys.version_info >= (3, 12): pytest.skip("Ignoring eventlet failures on Python 3.12") raise __import__('gunicorn.workers.geventlet') + + +class TestVersionRequirement: + """Tests for eventlet version requirement checks.""" + + def test_import_error_message(self): + """Test that ImportError gives correct version message.""" + with mock.patch.dict('sys.modules', {'eventlet': None}): + # Clear cached module if present + sys.modules.pop('gunicorn.workers.geventlet', None) + with pytest.raises(RuntimeError, match="eventlet 0.40.3"): + import importlib + import gunicorn.workers.geventlet + importlib.reload(gunicorn.workers.geventlet) + + def test_version_check_requires_0_40_3(self): + """Test that version check requires eventlet 0.40.3 or higher.""" + try: + import eventlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + from packaging.version import parse as parse_version + min_version = parse_version('0.40.3') + current_version = parse_version(eventlet.__version__) + + # If we got this far, the import succeeded, meaning version is sufficient + assert current_version >= min_version + + +@pytest.fixture +def eventlet_worker(): + """Fixture to create an EventletWorker instance for testing.""" + try: + import eventlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + from gunicorn.workers.geventlet import EventletWorker + + # Create a minimal mock config + cfg = mock.MagicMock() + cfg.keepalive = 2 + cfg.graceful_timeout = 30 + cfg.is_ssl = False + cfg.worker_connections = 1000 + + # Create worker with mocked dependencies + worker = EventletWorker.__new__(EventletWorker) + worker.cfg = cfg + worker.alive = True + worker.sockets = [] + worker.log = mock.MagicMock() + + return worker + + +class TestEventletWorker: + """Tests for EventletWorker class.""" + + def test_worker_class_exists(self): + """Test that EventletWorker class is properly defined.""" + try: + import eventlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + from gunicorn.workers.geventlet import EventletWorker + from gunicorn.workers.base_async import AsyncWorker + + assert issubclass(EventletWorker, AsyncWorker) + + def test_patch_method_calls_use_hub(self, eventlet_worker): + """Test that patch() calls hubs.use_hub(). + + hubs.use_hub() must be called in patch() (after fork) because it creates + OS resources like kqueue that don't survive fork. + """ + from eventlet import hubs + + with mock.patch.object(hubs, 'use_hub') as mock_use_hub: + with mock.patch('gunicorn.workers.geventlet.patch_sendfile'): + eventlet_worker.patch() + + mock_use_hub.assert_called_once() + + def test_patch_method_calls_patch_sendfile(self, eventlet_worker): + """Test that patch() calls patch_sendfile().""" + from eventlet import hubs + + with mock.patch.object(hubs, 'use_hub'): + with mock.patch('gunicorn.workers.geventlet.patch_sendfile') as mock_sf: + eventlet_worker.patch() + + mock_sf.assert_called_once() + + def test_monkey_patch_called_at_import_time(self): + """Test that monkey_patch is called at module import time. + + Note: hubs.use_hub() and eventlet.monkey_patch() are called at module + import time (not in patch()) to ensure all imports are properly patched. + This test verifies the module was patched by checking eventlet state. + """ + try: + import eventlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + # Verify eventlet has been patched by checking that socket is patched + import socket + from eventlet.greenio import GreenSocket + + # After monkey patching, socket.socket should be GreenSocket + assert socket.socket is GreenSocket + + def test_timeout_ctx_returns_eventlet_timeout(self, eventlet_worker): + """Test that timeout_ctx() returns an eventlet.Timeout.""" + import eventlet + + timeout = eventlet_worker.timeout_ctx() + assert isinstance(timeout, eventlet.Timeout) + + def test_timeout_ctx_uses_keepalive_config(self, eventlet_worker): + """Test that timeout_ctx() uses cfg.keepalive value.""" + import eventlet + + eventlet_worker.cfg.keepalive = 5 + with mock.patch.object(eventlet, 'Timeout') as mock_timeout: + eventlet_worker.timeout_ctx() + + mock_timeout.assert_called_once_with(5, False) + + def test_timeout_ctx_with_no_keepalive(self, eventlet_worker): + """Test that timeout_ctx() handles no keepalive (None or 0).""" + import eventlet + + eventlet_worker.cfg.keepalive = 0 + with mock.patch.object(eventlet, 'Timeout') as mock_timeout: + eventlet_worker.timeout_ctx() + + mock_timeout.assert_called_once_with(None, False) + + def test_handle_quit_spawns_greenthread(self, eventlet_worker): + """Test that handle_quit() spawns a greenthread.""" + import eventlet + + with mock.patch.object(eventlet, 'spawn') as mock_spawn: + eventlet_worker.handle_quit(None, None) + + mock_spawn.assert_called_once() + + def test_handle_usr1_spawns_greenthread(self, eventlet_worker): + """Test that handle_usr1() spawns a greenthread.""" + import eventlet + + with mock.patch.object(eventlet, 'spawn') as mock_spawn: + eventlet_worker.handle_usr1(None, None) + + mock_spawn.assert_called_once() + + def test_handle_wraps_ssl_when_configured(self, eventlet_worker): + """Test that handle() wraps socket with SSL when is_ssl is True.""" + from gunicorn.workers import geventlet + + eventlet_worker.cfg.is_ssl = True + mock_client = mock.MagicMock() + mock_listener = mock.MagicMock() + + with mock.patch.object(geventlet, 'ssl_wrap_socket') as mock_ssl: + mock_ssl.return_value = mock_client + with mock.patch('gunicorn.workers.base_async.AsyncWorker.handle'): + eventlet_worker.handle(mock_listener, mock_client, ('127.0.0.1', 8000)) + + mock_ssl.assert_called_once_with(mock_client, eventlet_worker.cfg) + + def test_handle_no_ssl_when_not_configured(self, eventlet_worker): + """Test that handle() does not wrap SSL when is_ssl is False.""" + from gunicorn.workers import geventlet + + eventlet_worker.cfg.is_ssl = False + mock_client = mock.MagicMock() + mock_listener = mock.MagicMock() + + with mock.patch.object(geventlet, 'ssl_wrap_socket') as mock_ssl: + with mock.patch('gunicorn.workers.base_async.AsyncWorker.handle'): + eventlet_worker.handle(mock_listener, mock_client, ('127.0.0.1', 8000)) + + mock_ssl.assert_not_called() + + +class TestAlreadyHandled: + """Tests for is_already_handled() method.""" + + def test_is_already_handled_new_style(self, eventlet_worker): + """Test is_already_handled with eventlet >= 0.30.3 (WSGI_LOCAL).""" + from gunicorn.workers import geventlet + + # Mock the new-style WSGI_LOCAL.already_handled + mock_wsgi_local = mock.MagicMock() + mock_wsgi_local.already_handled = True + + with mock.patch.object(geventlet, 'EVENTLET_WSGI_LOCAL', mock_wsgi_local): + with pytest.raises(StopIteration): + eventlet_worker.is_already_handled(mock.MagicMock()) + + def test_is_already_handled_old_style(self, eventlet_worker): + """Test is_already_handled with eventlet < 0.30.3 (ALREADY_HANDLED).""" + from gunicorn.workers import geventlet + + sentinel = object() + + with mock.patch.object(geventlet, 'EVENTLET_WSGI_LOCAL', None): + with mock.patch.object(geventlet, 'EVENTLET_ALREADY_HANDLED', sentinel): + with pytest.raises(StopIteration): + eventlet_worker.is_already_handled(sentinel) + + def test_is_already_handled_returns_parent_result(self, eventlet_worker): + """Test is_already_handled falls through to parent when not handled.""" + from gunicorn.workers import geventlet + + with mock.patch.object(geventlet, 'EVENTLET_WSGI_LOCAL', None): + with mock.patch.object(geventlet, 'EVENTLET_ALREADY_HANDLED', None): + with mock.patch('gunicorn.workers.base_async.AsyncWorker.is_already_handled') as mock_parent: + mock_parent.return_value = False + result = eventlet_worker.is_already_handled(mock.MagicMock()) + + assert result is False + mock_parent.assert_called_once() + + +class TestPatchSendfile: + """Tests for patch_sendfile() function.""" + + def test_patch_sendfile_adds_method_when_missing(self): + """Test that patch_sendfile adds sendfile to GreenSocket if missing.""" + try: + import eventlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + from gunicorn.workers.geventlet import patch_sendfile, _eventlet_socket_sendfile + from eventlet.greenio import GreenSocket + + # Remove sendfile if it exists + original = getattr(GreenSocket, 'sendfile', None) + if hasattr(GreenSocket, 'sendfile'): + delattr(GreenSocket, 'sendfile') + + try: + patch_sendfile() + assert hasattr(GreenSocket, 'sendfile') + assert GreenSocket.sendfile == _eventlet_socket_sendfile + finally: + # Restore original state + if original is not None: + GreenSocket.sendfile = original + elif hasattr(GreenSocket, 'sendfile'): + delattr(GreenSocket, 'sendfile') + + def test_patch_sendfile_preserves_existing_method(self): + """Test that patch_sendfile does not override existing sendfile.""" + try: + import eventlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + from gunicorn.workers.geventlet import patch_sendfile + from eventlet.greenio import GreenSocket + + # If sendfile exists, it should be preserved + if hasattr(GreenSocket, 'sendfile'): + original = GreenSocket.sendfile + patch_sendfile() + assert GreenSocket.sendfile == original + + +class TestEventletSocketSendfile: + """Tests for _eventlet_socket_sendfile() function.""" + + def test_sendfile_raises_on_non_blocking(self): + """Test that sendfile raises ValueError for non-blocking sockets.""" + try: + import eventlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + from gunicorn.workers.geventlet import _eventlet_socket_sendfile + + mock_socket = mock.MagicMock() + mock_socket.gettimeout.return_value = 0 + + with pytest.raises(ValueError, match="non-blocking"): + _eventlet_socket_sendfile(mock_socket, mock.MagicMock()) + + def test_sendfile_seeks_to_offset(self): + """Test that sendfile seeks to offset if provided.""" + try: + import eventlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + from gunicorn.workers.geventlet import _eventlet_socket_sendfile + + mock_socket = mock.MagicMock() + mock_socket.gettimeout.return_value = 1 + mock_file = mock.MagicMock() + mock_file.read.return_value = b'' + + _eventlet_socket_sendfile(mock_socket, mock_file, offset=100) + + mock_file.seek.assert_any_call(100) + + def test_sendfile_returns_total_sent(self): + """Test that sendfile returns the total bytes sent.""" + try: + import eventlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + from gunicorn.workers.geventlet import _eventlet_socket_sendfile + + mock_socket = mock.MagicMock() + mock_socket.gettimeout.return_value = 1 + mock_socket.send.return_value = 10 + + mock_file = mock.MagicMock() + mock_file.read.side_effect = [b'x' * 10, b''] + + result = _eventlet_socket_sendfile(mock_socket, mock_file) + + assert result == 10 + + +class TestEventletServe: + """Tests for _eventlet_serve() function.""" + + def test_serve_creates_green_pool(self): + """Test that _eventlet_serve creates a GreenPool.""" + try: + import eventlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + from gunicorn.workers.geventlet import _eventlet_serve + + mock_sock = mock.MagicMock() + mock_sock.accept.side_effect = eventlet.StopServe() + + with mock.patch.object(eventlet.greenpool, 'GreenPool') as mock_pool: + mock_pool_instance = mock.MagicMock() + mock_pool.return_value = mock_pool_instance + mock_pool_instance.waitall.return_value = None + + _eventlet_serve(mock_sock, mock.MagicMock(), 100) + + mock_pool.assert_called_once_with(100) + + +class TestEventletStop: + """Tests for _eventlet_stop() function.""" + + def test_stop_waits_for_client(self): + """Test that _eventlet_stop waits for the client greenlet.""" + try: + import eventlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + from gunicorn.workers.geventlet import _eventlet_stop + + mock_client = mock.MagicMock() + mock_server = mock.MagicMock() + mock_conn = mock.MagicMock() + + _eventlet_stop(mock_client, mock_server, mock_conn) + + mock_client.wait.assert_called_once() + mock_conn.close.assert_called_once() + + def test_stop_closes_connection_on_greenlet_exit(self): + """Test that connection is closed even on GreenletExit.""" + try: + import eventlet + import greenlet + except (ImportError, AttributeError): + pytest.skip("eventlet not available") + + from gunicorn.workers.geventlet import _eventlet_stop + + mock_client = mock.MagicMock() + mock_client.wait.side_effect = greenlet.GreenletExit() + mock_server = mock.MagicMock() + mock_conn = mock.MagicMock() + + # Should not raise + _eventlet_stop(mock_client, mock_server, mock_conn) + + mock_conn.close.assert_called_once()