7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
787 lines
28 KiB
Python
787 lines
28 KiB
Python
"""Tests for safe_requests module - SSRF-protected HTTP requests."""
|
|
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
import requests
|
|
|
|
from local_deep_research.security import ssrf_validator
|
|
from local_deep_research.security.safe_requests import (
|
|
safe_get,
|
|
safe_post,
|
|
SafeSession,
|
|
DEFAULT_TIMEOUT,
|
|
MAX_RESPONSE_SIZE,
|
|
)
|
|
|
|
|
|
class TestSafeGetFunction:
|
|
"""Tests for safe_get function."""
|
|
|
|
def test_valid_url_makes_request(self):
|
|
"""Should make request to valid external URL."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get"
|
|
) as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_get.return_value = mock_response
|
|
|
|
response = safe_get("https://example.com")
|
|
|
|
mock_get.assert_called_once()
|
|
assert response == mock_response
|
|
|
|
def test_rejects_invalid_url(self):
|
|
"""Should raise ValueError for URLs failing SSRF validation."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=False,
|
|
):
|
|
with pytest.raises(ValueError, match="SSRF"):
|
|
safe_get("http://127.0.0.1/admin")
|
|
|
|
def test_uses_default_timeout(self):
|
|
"""Should use default timeout when not specified."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get"
|
|
) as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_get.return_value = mock_response
|
|
|
|
safe_get("https://example.com")
|
|
|
|
_, kwargs = mock_get.call_args
|
|
assert kwargs["timeout"] == DEFAULT_TIMEOUT
|
|
|
|
def test_custom_timeout(self):
|
|
"""Should use custom timeout when provided."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get"
|
|
) as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_get.return_value = mock_response
|
|
|
|
safe_get("https://example.com", timeout=60)
|
|
|
|
_, kwargs = mock_get.call_args
|
|
assert kwargs["timeout"] == 60
|
|
|
|
def test_underlying_requests_always_gets_redirects_disabled(self):
|
|
"""The underlying requests.get() always receives allow_redirects=False;
|
|
safe_get handles redirects manually with SSRF validation."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get"
|
|
) as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_get.return_value = mock_response
|
|
|
|
safe_get("https://example.com")
|
|
|
|
_, kwargs = mock_get.call_args
|
|
assert kwargs["allow_redirects"] is False
|
|
|
|
def test_can_enable_redirects(self):
|
|
"""With allow_redirects=True, redirects are still disabled at
|
|
requests level (handled manually for SSRF validation)."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get"
|
|
) as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_get.return_value = mock_response
|
|
|
|
safe_get("https://example.com", allow_redirects=True)
|
|
|
|
_, kwargs = mock_get.call_args
|
|
# Redirects are always disabled at the requests level;
|
|
# safe_get handles them manually with SSRF validation
|
|
assert kwargs["allow_redirects"] is False
|
|
|
|
def test_passes_params(self):
|
|
"""Should pass URL parameters."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get"
|
|
) as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_get.return_value = mock_response
|
|
|
|
params = {"q": "test", "page": "1"}
|
|
safe_get("https://example.com", params=params)
|
|
|
|
args, _ = mock_get.call_args
|
|
assert args == ("https://example.com",)
|
|
_, kwargs = mock_get.call_args
|
|
assert "params" not in kwargs or kwargs.get("params") == params
|
|
|
|
def test_oversized_response_raises_error(self):
|
|
"""Oversized responses are rejected with ValueError."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get"
|
|
) as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {
|
|
"Content-Length": str(MAX_RESPONSE_SIZE + 1)
|
|
}
|
|
mock_get.return_value = mock_response
|
|
|
|
with pytest.raises(ValueError, match="Response too large"):
|
|
safe_get("https://example.com")
|
|
|
|
def test_accepts_response_within_limit(self):
|
|
"""Should accept response within size limit."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get"
|
|
) as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"Content-Length": str(1024)}
|
|
mock_get.return_value = mock_response
|
|
|
|
response = safe_get("https://example.com")
|
|
assert response == mock_response
|
|
|
|
def test_handles_invalid_content_length(self):
|
|
"""Should ignore invalid Content-Length values."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get"
|
|
) as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"Content-Length": "not-a-number"}
|
|
mock_get.return_value = mock_response
|
|
|
|
# Should not raise
|
|
response = safe_get("https://example.com")
|
|
assert response == mock_response
|
|
|
|
def test_reraises_timeout(self):
|
|
"""Should re-raise timeout exceptions."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get",
|
|
side_effect=requests.Timeout("timeout"),
|
|
):
|
|
with pytest.raises(requests.Timeout):
|
|
safe_get("https://example.com")
|
|
|
|
def test_reraises_request_exception(self):
|
|
"""Should re-raise request exceptions."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get",
|
|
side_effect=requests.RequestException("connection error"),
|
|
):
|
|
with pytest.raises(requests.RequestException):
|
|
safe_get("https://example.com")
|
|
|
|
def test_allow_localhost_parameter(self):
|
|
"""Should pass allow_localhost to validate_url."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
) as mock_validate:
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get"
|
|
) as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_get.return_value = mock_response
|
|
|
|
safe_get("http://localhost:8080", allow_localhost=True)
|
|
|
|
mock_validate.assert_called_once_with(
|
|
"http://localhost:8080",
|
|
allow_localhost=True,
|
|
allow_private_ips=False,
|
|
)
|
|
|
|
def test_allow_private_ips_parameter(self):
|
|
"""Should pass allow_private_ips to validate_url."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
) as mock_validate:
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.get"
|
|
) as mock_get:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_get.return_value = mock_response
|
|
|
|
safe_get("http://192.168.1.1", allow_private_ips=True)
|
|
|
|
mock_validate.assert_called_once_with(
|
|
"http://192.168.1.1",
|
|
allow_localhost=False,
|
|
allow_private_ips=True,
|
|
)
|
|
|
|
|
|
class TestSafePostFunction:
|
|
"""Tests for safe_post function."""
|
|
|
|
def test_valid_url_makes_request(self):
|
|
"""Should make POST request to valid external URL."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.post"
|
|
) as mock_post:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_post.return_value = mock_response
|
|
|
|
response = safe_post(
|
|
"https://example.com/api", json={"key": "value"}
|
|
)
|
|
|
|
mock_post.assert_called_once()
|
|
assert response == mock_response
|
|
|
|
def test_rejects_invalid_url(self):
|
|
"""Should raise ValueError for URLs failing SSRF validation."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=False,
|
|
):
|
|
with pytest.raises(ValueError, match="SSRF"):
|
|
safe_post("http://169.254.169.254/metadata")
|
|
|
|
def test_passes_data_parameter(self):
|
|
"""Should pass data parameter for form data."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.post"
|
|
) as mock_post:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_post.return_value = mock_response
|
|
|
|
safe_post("https://example.com", data="raw data")
|
|
|
|
_, kwargs = mock_post.call_args
|
|
assert kwargs.get("data") == "raw data"
|
|
|
|
def test_passes_json_parameter(self):
|
|
"""Should pass json parameter for JSON data."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.post"
|
|
) as mock_post:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_post.return_value = mock_response
|
|
|
|
json_data = {"key": "value"}
|
|
safe_post("https://example.com", json=json_data)
|
|
|
|
_, kwargs = mock_post.call_args
|
|
assert kwargs.get("json") == json_data
|
|
|
|
def test_uses_default_timeout(self):
|
|
"""Should use default timeout when not specified."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.post"
|
|
) as mock_post:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_post.return_value = mock_response
|
|
|
|
safe_post("https://example.com")
|
|
|
|
_, kwargs = mock_post.call_args
|
|
assert kwargs["timeout"] == DEFAULT_TIMEOUT
|
|
|
|
def test_underlying_requests_always_gets_redirects_disabled(self):
|
|
"""The underlying requests.post() always receives allow_redirects=False;
|
|
safe_post handles redirects manually with SSRF validation."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.post"
|
|
) as mock_post:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_post.return_value = mock_response
|
|
|
|
safe_post("https://example.com")
|
|
|
|
_, kwargs = mock_post.call_args
|
|
assert kwargs["allow_redirects"] is False
|
|
|
|
def test_can_enable_redirects(self):
|
|
"""With allow_redirects=True, redirects are still disabled at
|
|
requests level (handled manually for SSRF validation)."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.post"
|
|
) as mock_post:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {}
|
|
mock_post.return_value = mock_response
|
|
|
|
safe_post("https://example.com", allow_redirects=True)
|
|
|
|
_, kwargs = mock_post.call_args
|
|
# Redirects are always disabled at the requests level;
|
|
# safe_post handles them manually with SSRF validation
|
|
assert kwargs["allow_redirects"] is False
|
|
|
|
def test_oversized_response_raises_error(self):
|
|
"""Oversized responses are rejected with ValueError."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.post"
|
|
) as mock_post:
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {
|
|
"Content-Length": str(MAX_RESPONSE_SIZE + 1)
|
|
}
|
|
mock_post.return_value = mock_response
|
|
|
|
with pytest.raises(ValueError, match="Response too large"):
|
|
safe_post("https://example.com")
|
|
|
|
def test_reraises_timeout(self):
|
|
"""Should re-raise timeout exceptions."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.requests.post",
|
|
side_effect=requests.Timeout("timeout"),
|
|
):
|
|
with pytest.raises(requests.Timeout):
|
|
safe_post("https://example.com")
|
|
|
|
|
|
class TestSafeSession:
|
|
"""Tests for SafeSession class."""
|
|
|
|
def test_init_default_values(self):
|
|
"""Should initialize with default security settings."""
|
|
session = SafeSession()
|
|
assert session.allow_localhost is False
|
|
assert session.allow_private_ips is False
|
|
|
|
def test_init_allow_localhost(self):
|
|
"""Should accept allow_localhost parameter."""
|
|
session = SafeSession(allow_localhost=True)
|
|
assert session.allow_localhost is True
|
|
assert session.allow_private_ips is False
|
|
|
|
def test_init_allow_private_ips(self):
|
|
"""Should accept allow_private_ips parameter."""
|
|
session = SafeSession(allow_private_ips=True)
|
|
assert session.allow_localhost is False
|
|
assert session.allow_private_ips is True
|
|
|
|
def test_request_validates_url(self):
|
|
"""Should validate URL before making request."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=False,
|
|
):
|
|
session = SafeSession()
|
|
with pytest.raises(ValueError, match="SSRF"):
|
|
session.request("GET", "http://127.0.0.1/admin")
|
|
|
|
def test_request_makes_call_on_valid_url(self):
|
|
"""Should make request when URL is valid."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch.object(requests.Session, "request") as mock_request:
|
|
mock_response = MagicMock()
|
|
mock_request.return_value = mock_response
|
|
|
|
session = SafeSession()
|
|
response = session.request("GET", "https://example.com")
|
|
|
|
mock_request.assert_called_once()
|
|
assert response == mock_response
|
|
|
|
def test_request_uses_default_timeout(self):
|
|
"""Should set default timeout if not provided."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch.object(requests.Session, "request") as mock_request:
|
|
mock_response = MagicMock()
|
|
mock_request.return_value = mock_response
|
|
|
|
session = SafeSession()
|
|
session.request("GET", "https://example.com")
|
|
|
|
_, kwargs = mock_request.call_args
|
|
assert kwargs["timeout"] == DEFAULT_TIMEOUT
|
|
|
|
def test_request_respects_custom_timeout(self):
|
|
"""Should respect custom timeout when provided."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with patch.object(requests.Session, "request") as mock_request:
|
|
mock_response = MagicMock()
|
|
mock_request.return_value = mock_response
|
|
|
|
session = SafeSession()
|
|
session.request("GET", "https://example.com", timeout=120)
|
|
|
|
_, kwargs = mock_request.call_args
|
|
assert kwargs["timeout"] == 120
|
|
|
|
def test_context_manager(self):
|
|
"""Should work as context manager."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
):
|
|
with SafeSession() as session:
|
|
assert isinstance(session, SafeSession)
|
|
|
|
def test_passes_allow_localhost_to_validate(self):
|
|
"""Should pass allow_localhost to validate_url."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
) as mock_validate:
|
|
with patch.object(requests.Session, "request"):
|
|
session = SafeSession(allow_localhost=True)
|
|
session.request("GET", "http://localhost:8080")
|
|
|
|
mock_validate.assert_called_once_with(
|
|
"http://localhost:8080",
|
|
allow_localhost=True,
|
|
allow_private_ips=False,
|
|
)
|
|
|
|
def test_passes_allow_private_ips_to_validate(self):
|
|
"""Should pass allow_private_ips to validate_url."""
|
|
with patch.object(
|
|
ssrf_validator,
|
|
"validate_url",
|
|
return_value=True,
|
|
) as mock_validate:
|
|
with patch.object(requests.Session, "request"):
|
|
session = SafeSession(allow_private_ips=True)
|
|
session.request("GET", "http://192.168.1.1")
|
|
|
|
mock_validate.assert_called_once_with(
|
|
"http://192.168.1.1",
|
|
allow_localhost=False,
|
|
allow_private_ips=True,
|
|
)
|
|
|
|
|
|
class TestConstants:
|
|
"""Tests for module constants."""
|
|
|
|
def test_default_timeout_reasonable(self):
|
|
"""DEFAULT_TIMEOUT should be a reasonable value."""
|
|
assert DEFAULT_TIMEOUT == 30
|
|
assert isinstance(DEFAULT_TIMEOUT, int)
|
|
|
|
def test_max_response_size_reasonable(self):
|
|
"""MAX_RESPONSE_SIZE should be a reasonable value (1GB)."""
|
|
assert MAX_RESPONSE_SIZE == 1024 * 1024 * 1024 # 1GB
|
|
assert isinstance(MAX_RESPONSE_SIZE, int)
|
|
|
|
|
|
class TestUserAgentInjection:
|
|
"""The project User-Agent should be injected automatically when the
|
|
caller doesn't supply one, and preserved when they do.
|
|
|
|
This locks in the behavior added by PR #3081 — academic API endpoints
|
|
(arXiv, OpenAlex, PubMed, …) need to identify the caller, and we
|
|
don't want every call site to remember to set the header manually.
|
|
"""
|
|
|
|
def _make_response(self):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {}
|
|
mock_response.url = "https://example.com"
|
|
return mock_response
|
|
|
|
def test_safe_get_injects_user_agent_when_missing(self):
|
|
"""safe_get with no headers should auto-inject the project UA."""
|
|
from local_deep_research.constants import USER_AGENT
|
|
|
|
with (
|
|
patch.object(ssrf_validator, "validate_url", return_value=True),
|
|
patch(
|
|
"local_deep_research.security.safe_requests.requests.get",
|
|
return_value=self._make_response(),
|
|
) as mock_get,
|
|
):
|
|
safe_get("https://example.com")
|
|
|
|
_, kwargs = mock_get.call_args
|
|
assert "headers" in kwargs
|
|
assert kwargs["headers"]["User-Agent"] == USER_AGENT
|
|
|
|
def test_safe_get_preserves_explicit_user_agent(self):
|
|
"""A caller-supplied User-Agent must NOT be overwritten."""
|
|
custom_ua = "Mozilla/5.0 Custom/1.0"
|
|
|
|
with (
|
|
patch.object(ssrf_validator, "validate_url", return_value=True),
|
|
patch(
|
|
"local_deep_research.security.safe_requests.requests.get",
|
|
return_value=self._make_response(),
|
|
) as mock_get,
|
|
):
|
|
safe_get("https://example.com", headers={"User-Agent": custom_ua})
|
|
|
|
_, kwargs = mock_get.call_args
|
|
assert kwargs["headers"]["User-Agent"] == custom_ua
|
|
|
|
def test_safe_get_user_agent_check_is_case_insensitive(self):
|
|
"""A `user-agent` (lowercase) header should be respected."""
|
|
custom_ua = "Mozilla/5.0 Custom/1.0"
|
|
|
|
with (
|
|
patch.object(ssrf_validator, "validate_url", return_value=True),
|
|
patch(
|
|
"local_deep_research.security.safe_requests.requests.get",
|
|
return_value=self._make_response(),
|
|
) as mock_get,
|
|
):
|
|
safe_get("https://example.com", headers={"user-agent": custom_ua})
|
|
|
|
_, kwargs = mock_get.call_args
|
|
# Original lowercase key preserved; no second User-Agent added
|
|
assert kwargs["headers"]["user-agent"] == custom_ua
|
|
assert "User-Agent" not in kwargs["headers"]
|
|
|
|
def test_safe_get_does_not_mutate_caller_headers_dict(self):
|
|
"""safe_get must not add User-Agent to the caller's dict."""
|
|
caller_headers = {"X-Custom": "value"}
|
|
|
|
with (
|
|
patch.object(ssrf_validator, "validate_url", return_value=True),
|
|
patch(
|
|
"local_deep_research.security.safe_requests.requests.get",
|
|
return_value=self._make_response(),
|
|
),
|
|
):
|
|
safe_get("https://example.com", headers=caller_headers)
|
|
|
|
# Caller's dict is untouched — User-Agent injection is on a
|
|
# copy, not the original.
|
|
assert "User-Agent" not in caller_headers
|
|
assert caller_headers == {"X-Custom": "value"}
|
|
|
|
def test_safe_post_injects_user_agent_when_missing(self):
|
|
"""safe_post should auto-inject the project UA."""
|
|
from local_deep_research.constants import USER_AGENT
|
|
|
|
with (
|
|
patch.object(ssrf_validator, "validate_url", return_value=True),
|
|
patch(
|
|
"local_deep_research.security.safe_requests.requests.post",
|
|
return_value=self._make_response(),
|
|
) as mock_post,
|
|
):
|
|
safe_post("https://example.com", data={"k": "v"})
|
|
|
|
_, kwargs = mock_post.call_args
|
|
assert kwargs["headers"]["User-Agent"] == USER_AGENT
|
|
|
|
def test_safe_post_preserves_explicit_user_agent(self):
|
|
custom_ua = "Mozilla/5.0 Custom/1.0"
|
|
|
|
with (
|
|
patch.object(ssrf_validator, "validate_url", return_value=True),
|
|
patch(
|
|
"local_deep_research.security.safe_requests.requests.post",
|
|
return_value=self._make_response(),
|
|
) as mock_post,
|
|
):
|
|
safe_post(
|
|
"https://example.com",
|
|
data={"k": "v"},
|
|
headers={"User-Agent": custom_ua},
|
|
)
|
|
|
|
_, kwargs = mock_post.call_args
|
|
assert kwargs["headers"]["User-Agent"] == custom_ua
|
|
|
|
|
|
class TestParserDifferentialEndToEnd:
|
|
"""
|
|
End-to-end integration tests for the parser-differential SSRF bypass
|
|
fix (GHSA-g23j-2vwm-5c25).
|
|
|
|
Approach: bind a TCP socket on 127.0.0.1:<random> WITHOUT calling
|
|
listen() — the kernel responds RST to any incoming connect. If the
|
|
fix regresses and ``safe_get`` actually attempts to connect to the
|
|
bound port, ``requests`` raises ``ConnectionError`` (kernel RST), so
|
|
a strict ``pytest.raises(ValueError, match=...)`` distinguishes
|
|
"validator caught it" from "validator failed and the kernel saved us".
|
|
"""
|
|
|
|
@staticmethod
|
|
def _bind_unused_port():
|
|
import socket as _socket
|
|
|
|
sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock
|
|
|
|
def test_safe_get_blocks_parser_differential_no_socket_connect(self):
|
|
from local_deep_research.security.safe_requests import safe_get
|
|
|
|
sock = self._bind_unused_port()
|
|
try:
|
|
port = sock.getsockname()[1]
|
|
bypass_url = f"http://127.0.0.1:{port}\\@1.1.1.1"
|
|
with pytest.raises(ValueError, match="SSRF|security validation"):
|
|
safe_get(bypass_url, timeout=2)
|
|
finally:
|
|
sock.close()
|
|
|
|
def test_safe_post_blocks_parser_differential(self):
|
|
from local_deep_research.security.safe_requests import safe_post
|
|
|
|
sock = self._bind_unused_port()
|
|
try:
|
|
port = sock.getsockname()[1]
|
|
bypass_url = f"http://127.0.0.1:{port}\\@1.1.1.1"
|
|
with pytest.raises(ValueError, match="SSRF|security validation"):
|
|
safe_post(bypass_url, data={"k": "v"}, timeout=2)
|
|
finally:
|
|
sock.close()
|
|
|
|
def test_safesession_blocks_parser_differential(self):
|
|
"""SafeSession validates at both request() and send() — exercises
|
|
the double-validation path. This URL contains ``\\`` so Layer 1
|
|
catches it at request() before .prepare() canonicalises it."""
|
|
from local_deep_research.security.safe_requests import SafeSession
|
|
|
|
sock = self._bind_unused_port()
|
|
try:
|
|
port = sock.getsockname()[1]
|
|
bypass_url = f"http://127.0.0.1:{port}\\@1.1.1.1"
|
|
with SafeSession() as sess:
|
|
with pytest.raises(
|
|
ValueError, match="SSRF|security validation"
|
|
):
|
|
sess.get(bypass_url, timeout=2)
|
|
finally:
|
|
sock.close()
|
|
|
|
def test_safesession_send_blocks_canonicalised_form(self):
|
|
"""
|
|
Layer-2 verification: ``SafeSession.send()`` is called with a
|
|
``PreparedRequest`` whose URL contains ``%5C`` (the canonicalised
|
|
form of ``\\``). Layer 1 doesn't match ``%5C``, so Layer 2's
|
|
urllib3-based hostname extraction is what blocks this — proving
|
|
Layer 2 carries the load on this path.
|
|
"""
|
|
from local_deep_research.security.safe_requests import SafeSession
|
|
|
|
with SafeSession() as sess:
|
|
req = requests.Request("GET", "http://127.0.0.1:6666/%5C@1.1.1.1")
|
|
prepared = sess.prepare_request(req)
|
|
with pytest.raises(ValueError, match="SSRF|security validation"):
|
|
sess.send(prepared, timeout=2)
|