feat: enhance HTTP handling and configuration
All checks were successful
CI / lint-and-test (push) Successful in 24s
All checks were successful
CI / lint-and-test (push) Successful in 24s
- Introduced a new utility function `safe_urlopen` to ensure only allowed URL schemes (http, https) are opened, enhancing security against path traversal vulnerabilities. - Updated the `run.py` and `calendar_ics.py` files to utilize `safe_urlopen` for HTTP requests, improving error handling and security. - Added `HTTP_HOST` configuration to the settings, allowing dynamic binding of the HTTP server host. - Revised the `.env.example` file to include the new `HTTP_HOST` variable with a description. - Enhanced tests for `safe_urlopen` to validate behavior with disallowed URL schemes and ensure proper integration in existing functionality.
This commit is contained in:
@@ -129,6 +129,17 @@ class TestGetCalendarEvents:
|
||||
def test_empty_url_returns_empty(self):
|
||||
assert mod.get_calendar_events("", "2025-01-01", "2025-01-31") == []
|
||||
|
||||
def test_disallowed_url_scheme_returns_empty(self):
|
||||
"""get_calendar_events: file:// or ftp:// URL does not call urlopen, returns []."""
|
||||
result = mod.get_calendar_events(
|
||||
"file:///etc/passwd", "2025-01-01", "2025-01-31"
|
||||
)
|
||||
assert result == []
|
||||
result = mod.get_calendar_events(
|
||||
"ftp://example.com/cal.ics", "2025-01-01", "2025-01-31"
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_from_after_to_returns_empty(self):
|
||||
assert (
|
||||
mod.get_calendar_events(
|
||||
|
||||
56
tests/test_http_client.py
Normal file
56
tests/test_http_client.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Tests for duty_teller.utils.http_client (safe_urlopen scheme check)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from urllib.request import Request
|
||||
|
||||
from duty_teller.utils.http_client import safe_urlopen
|
||||
|
||||
|
||||
def test_safe_urlopen_rejects_file_scheme():
|
||||
"""safe_urlopen: file:// URL raises ValueError and does not call urlopen."""
|
||||
req = Request("file:///etc/passwd")
|
||||
with patch("duty_teller.utils.http_client.urlopen") as mock_urlopen:
|
||||
with pytest.raises(ValueError, match="scheme not allowed"):
|
||||
with safe_urlopen(req, timeout=5):
|
||||
pass
|
||||
mock_urlopen.assert_not_called()
|
||||
|
||||
|
||||
def test_safe_urlopen_rejects_ftp_scheme():
|
||||
"""safe_urlopen: ftp:// URL raises ValueError and does not call urlopen."""
|
||||
req = Request("ftp://example.com/file.txt")
|
||||
with patch("duty_teller.utils.http_client.urlopen") as mock_urlopen:
|
||||
with pytest.raises(ValueError, match="scheme not allowed"):
|
||||
with safe_urlopen(req, timeout=5):
|
||||
pass
|
||||
mock_urlopen.assert_not_called()
|
||||
|
||||
|
||||
def test_safe_urlopen_allows_https():
|
||||
"""safe_urlopen: https:// URL is allowed and urlopen is called."""
|
||||
req = Request("https://api.telegram.org/bot123/setChatMenuButton")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
with patch(
|
||||
"duty_teller.utils.http_client.urlopen", return_value=mock_resp
|
||||
) as mock_urlopen:
|
||||
with safe_urlopen(req, timeout=10) as resp:
|
||||
assert resp is mock_resp
|
||||
mock_urlopen.assert_called_once_with(req, timeout=10)
|
||||
|
||||
|
||||
def test_safe_urlopen_allows_http_when_in_allowed_schemes():
|
||||
"""safe_urlopen: http:// URL is allowed when allowed_schemes includes http."""
|
||||
req = Request("http://localhost:8080/health")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
with patch(
|
||||
"duty_teller.utils.http_client.urlopen", return_value=mock_resp
|
||||
) as mock_urlopen:
|
||||
with safe_urlopen(req, timeout=5, allowed_schemes=("https", "http")) as resp:
|
||||
assert resp is mock_resp
|
||||
mock_urlopen.assert_called_once_with(req, timeout=5)
|
||||
@@ -76,32 +76,32 @@ def test_set_default_menu_button_skips_when_not_https():
|
||||
|
||||
|
||||
def test_set_default_menu_button_calls_telegram_api():
|
||||
"""_set_default_menu_button_webapp: when URL is https, calls Telegram API (mocked)."""
|
||||
"""_set_default_menu_button_webapp: when URL is https, calls safe_urlopen (Telegram API mocked)."""
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_BASE_URL = "https://example.com/"
|
||||
mock_cfg.BOT_TOKEN = "123:ABC"
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
with patch("duty_teller.run.safe_urlopen") as mock_safe_urlopen:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
mock_safe_urlopen.return_value = mock_resp
|
||||
_set_default_menu_button_webapp()
|
||||
mock_urlopen.assert_called_once()
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert "setChatMenuButton" in req.full_url
|
||||
assert "123:ABC" in req.full_url
|
||||
mock_safe_urlopen.assert_called_once()
|
||||
req = mock_safe_urlopen.call_args[0][0]
|
||||
assert "setChatMenuButton" in req.get_full_url()
|
||||
assert "123:ABC" in req.get_full_url()
|
||||
|
||||
|
||||
def test_set_default_menu_button_handles_urlopen_error():
|
||||
"""_set_default_menu_button_webapp: on urlopen error, logs and does not raise."""
|
||||
"""_set_default_menu_button_webapp: on safe_urlopen error, logs and does not raise."""
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_BASE_URL = "https://example.com/"
|
||||
mock_cfg.BOT_TOKEN = "123:ABC"
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_urlopen.side_effect = OSError("network error")
|
||||
with patch("duty_teller.run.safe_urlopen") as mock_safe_urlopen:
|
||||
mock_safe_urlopen.side_effect = OSError("network error")
|
||||
_set_default_menu_button_webapp()
|
||||
mock_urlopen.assert_called_once()
|
||||
mock_safe_urlopen.assert_called_once()
|
||||
|
||||
|
||||
def test_set_default_menu_button_handles_non_200_response():
|
||||
@@ -109,11 +109,25 @@ def test_set_default_menu_button_handles_non_200_response():
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_BASE_URL = "https://example.com/"
|
||||
mock_cfg.BOT_TOKEN = "123:ABC"
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
with patch("duty_teller.run.safe_urlopen") as mock_safe_urlopen:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 400
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
mock_safe_urlopen.return_value = mock_resp
|
||||
_set_default_menu_button_webapp()
|
||||
mock_urlopen.assert_called_once()
|
||||
mock_safe_urlopen.assert_called_once()
|
||||
|
||||
|
||||
def test_run_uvicorn_uses_config_host():
|
||||
"""_run_uvicorn: passes config.HTTP_HOST to uvicorn.Config."""
|
||||
with patch("uvicorn.Server") as mock_server_class:
|
||||
mock_server = MagicMock()
|
||||
mock_server.serve = MagicMock(return_value=asyncio.sleep(0))
|
||||
mock_server_class.return_value = mock_server
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.HTTP_HOST = "127.0.0.1"
|
||||
_run_uvicorn(MagicMock(), 8080)
|
||||
call_kwargs = mock_server_class.call_args[0][0]
|
||||
assert call_kwargs.host == "127.0.0.1"
|
||||
assert call_kwargs.port == 8080
|
||||
|
||||
Reference in New Issue
Block a user