mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 18:21:30 +08:00
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.
417 lines
15 KiB
Python
417 lines
15 KiB
Python
#
|
|
# This file is part of gunicorn released under the MIT license.
|
|
# See the NOTICE for more information.
|
|
|
|
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):
|
|
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()
|