"""SSRF guard for operator-configured LLM provider base_url. Even though the SSRF validator (``ssrf_validator.validate_url``) gates outbound HTTP through ``safe_requests``, LangChain provider SDKs (``ChatOpenAI``, ``ChatOllama``) use their own internal ``httpx`` client that bypasses ``safe_requests`` entirely. An authenticated user with permission to edit ``llm..url`` could otherwise route every inference call at internal services / cloud-credential endpoints. The fix gates ``base_url`` through ``assert_base_url_safe`` immediately after ``normalize_url`` and before the SDK constructor. These tests lock in: 1. Every IP in ``ALWAYS_BLOCKED_METADATA_IPS`` raises ValueError. 2. Legitimate localhost / RFC1918 destinations (Ollama / LM Studio / llama.cpp on private networks) keep working. 3. Validation runs BEFORE the SDK constructor (a future refactor that moved it after would silently regress). 4. Edge cases like empty / None base_url are rejected. """ from unittest.mock import MagicMock, Mock import pytest @pytest.mark.parametrize( "ip", [ "169.254.169.254", # AWS IMDS / Azure / OCI / DigitalOcean "169.254.170.2", # AWS ECS task metadata v3 "169.254.170.23", # AWS ECS task metadata v4 "169.254.0.23", # Tencent Cloud "100.100.100.200", # AlibabaCloud ], ) def test_assert_base_url_safe_rejects_all_metadata_ips(ip): """Every cloud-metadata IP in ALWAYS_BLOCKED_METADATA_IPS must raise — this is the load-bearing security property.""" from local_deep_research.security.ssrf_validator import ( assert_base_url_safe, ) with pytest.raises(ValueError, match="failed SSRF validation"): assert_base_url_safe(f"http://{ip}/", setting_key="llm.test.url") def test_assert_base_url_safe_accepts_localhost(): """Localhost is the typical Ollama / LM Studio / llama.cpp host.""" from local_deep_research.security.ssrf_validator import ( assert_base_url_safe, ) assert ( assert_base_url_safe( "http://localhost:11434/", setting_key="llm.ollama.url" ) == "http://localhost:11434/" ) def test_assert_base_url_safe_accepts_rfc1918(): """Docker / private-network deployments need RFC1918.""" from local_deep_research.security.ssrf_validator import ( assert_base_url_safe, ) assert ( assert_base_url_safe( "http://192.168.1.10:11434/", setting_key="llm.ollama.url" ) == "http://192.168.1.10:11434/" ) def test_assert_base_url_safe_rejects_empty_string(): from local_deep_research.security.ssrf_validator import ( assert_base_url_safe, ) with pytest.raises(ValueError, match="failed SSRF validation"): assert_base_url_safe("", setting_key="llm.x.url") def test_assert_base_url_safe_rejects_none(): """isinstance check in validate_url means None returns False, which means assert_base_url_safe raises rather than crashing on .strip().""" from local_deep_research.security.ssrf_validator import ( assert_base_url_safe, ) with pytest.raises(ValueError, match="failed SSRF validation"): assert_base_url_safe(None, setting_key="llm.x.url") def test_assert_base_url_safe_error_message_includes_setting_key(): """Operators see the error and need to know which setting to fix.""" from local_deep_research.security.ssrf_validator import ( assert_base_url_safe, ) with pytest.raises(ValueError, match=r"llm\.lmstudio\.url"): assert_base_url_safe( "http://169.254.169.254/", setting_key="llm.lmstudio.url" ) # --------------------------------------------------------------------- # Per-provider integration tests # --------------------------------------------------------------------- class TestOllamaBaseUrlSsrf: """OllamaProvider gates base_url at three sites: list_models_for_api, create_llm, is_available. Each behaves differently on rejection.""" def test_create_llm_rejects_aws_metadata(self): """create_llm propagates ValueError so the research-query caller sees a clear config error instead of silent SSRF.""" from local_deep_research.llm.providers.implementations.ollama import ( OllamaProvider, ) snapshot = {"llm.ollama.url": "http://169.254.169.254/"} with pytest.raises(ValueError, match="failed SSRF validation"): OllamaProvider.create_llm( model_name="llama3", settings_snapshot=snapshot ) def test_create_llm_validate_runs_before_chatollama_constructor( self, monkeypatch ): """A future refactor that moved the validate call below the SDK constructor would silently regress (constructor is mocked in tests). Make assert_base_url_safe raise; assert ChatOllama mock has call_count == 0. Patch the SYMBOL inside ollama.py — ``from langchain_ollama import ChatOllama`` binds the name locally at import time; patching ``langchain_ollama.ChatOllama`` would not affect that local binding. """ from local_deep_research.llm.providers.implementations.ollama import ( OllamaProvider, ) mock_ctor = MagicMock(return_value=Mock()) monkeypatch.setattr( "local_deep_research.llm.providers.implementations.ollama.ChatOllama", mock_ctor, ) monkeypatch.setattr( "local_deep_research.llm.providers.implementations.ollama.assert_base_url_safe", Mock(side_effect=ValueError("simulated SSRF rejection")), ) with pytest.raises(ValueError): OllamaProvider.create_llm( model_name="llama3", settings_snapshot={"llm.ollama.url": "http://example.com/"}, ) assert mock_ctor.call_count == 0, ( "ChatOllama was instantiated despite SSRF guard raising — " "ordering regression" ) def test_is_available_returns_false_on_bad_base_url(self): """is_available swallows ValueError and returns False so the model-list UI degrades gracefully.""" from local_deep_research.llm.providers.implementations.ollama import ( OllamaProvider, ) snapshot = {"llm.ollama.url": "http://169.254.169.254/"} assert OllamaProvider.is_available(settings_snapshot=snapshot) is False def test_list_models_for_api_returns_empty_on_bad_base_url(self): """list_models_for_api swallows ValueError and returns [].""" from local_deep_research.llm.providers.implementations.ollama import ( OllamaProvider, ) result = OllamaProvider.list_models_for_api( api_key="test", base_url="http://169.254.169.254/", ) assert result == [] class TestOpenAICompatibleBaseUrlSsrf: """OpenAICompatibleProvider gates base_url in create_llm and _create_llm_instance, but only when ``cls.url_setting`` is set (subclasses with hardcoded default_base_url skip validation).""" def test_subclass_with_url_setting_rejects_metadata(self): """A subclass with cls.url_setting set (e.g. CustomOpenAIEndpoint which uses llm.openai_endpoint.url) must reject metadata IPs.""" from local_deep_research.llm.providers.openai_base import ( OpenAICompatibleProvider, ) # Create a temporary subclass to exercise the cls.url_setting gate class _TestProvider(OpenAICompatibleProvider): provider_name = "TestProvider" url_setting = "llm.test.url" api_key_setting = None default_base_url = "http://169.254.169.254/" with pytest.raises(ValueError, match="failed SSRF validation"): _TestProvider.create_llm(model_name="test") def test_pure_provider_skips_validation(self, monkeypatch): """Providers with ``url_setting = None`` (Anthropic, OpenAI, OpenRouter, xAI, IONOS) must NOT invoke assert_base_url_safe. Their default_base_url is hardcoded; there's no operator URL to attack, so validation would be wasted work.""" from local_deep_research.llm.providers.openai_base import ( OpenAICompatibleProvider, ) mock_guard = Mock(return_value="passthrough") monkeypatch.setattr( "local_deep_research.llm.providers.openai_base.assert_base_url_safe", mock_guard, ) monkeypatch.setattr( "local_deep_research.llm.providers.openai_base.ChatOpenAI", Mock(), ) class _PureProvider(OpenAICompatibleProvider): provider_name = "PureProvider" url_setting = None api_key_setting = None default_base_url = "https://api.example.com/v1" _PureProvider.create_llm(model_name="test-model") assert mock_guard.call_count == 0, ( "Pure-default provider invoked SSRF guard unexpectedly" ) def test_validate_runs_before_chatopenai_constructor(self, monkeypatch): """Same ordering invariant as the Ollama test, for the OpenAI compat path. Patch the local binding of ChatOpenAI inside openai_base.py.""" from local_deep_research.llm.providers.openai_base import ( OpenAICompatibleProvider, ) mock_ctor = MagicMock(return_value=Mock()) monkeypatch.setattr( "local_deep_research.llm.providers.openai_base.ChatOpenAI", mock_ctor, ) monkeypatch.setattr( "local_deep_research.llm.providers.openai_base.assert_base_url_safe", Mock(side_effect=ValueError("simulated SSRF rejection")), ) class _GuardedProvider(OpenAICompatibleProvider): provider_name = "GuardedProvider" url_setting = "llm.guarded.url" api_key_setting = None default_base_url = "https://api.example.com/v1" with pytest.raises(ValueError): _GuardedProvider.create_llm(model_name="test-model") assert mock_ctor.call_count == 0, ( "ChatOpenAI was instantiated despite SSRF guard raising — " "ordering regression" ) class TestOpenAICompatibleListModelsSsrf: """OpenAICompatibleProvider.list_models_for_api (used by CustomOpenAIEndpoint / LMStudio / LlamaCpp) gates base_url through the SSRF guard when ``cls.url_setting`` is set. On rejection it must degrade gracefully and return [] (model-listing should NOT 500).""" def test_list_models_for_api_returns_empty_on_metadata_url( self, monkeypatch, loguru_caplog ): """A subclass with cls.url_setting set must return [] (and log a warning) when base_url is a blocked metadata endpoint, without ever instantiating the OpenAI SDK client.""" from local_deep_research.llm.providers.openai_base import ( OpenAICompatibleProvider, ) # If the guard failed to short-circuit, the SDK client would be # constructed; make that an explicit failure so the test can't pass # for the wrong reason. import openai def _boom(*args, **kwargs): raise AssertionError( "OpenAI client constructed despite blocked base_url" ) monkeypatch.setattr(openai, "OpenAI", _boom) class _TestProvider(OpenAICompatibleProvider): provider_name = "TestListProvider" url_setting = "llm.test.url" api_key_setting = None default_base_url = "https://api.example.com/v1" @classmethod def requires_auth_for_models(cls): return False with loguru_caplog.at_level("WARNING"): result = _TestProvider.list_models_for_api( api_key="test-key", base_url="http://169.254.169.254/", ) assert result == [] assert "SSRF validation" in loguru_caplog.text, ( "expected a warning naming SSRF validation" ) def test_list_models_for_api_allows_localhost(self, monkeypatch): """Legitimate local endpoints (LM Studio / llama.cpp on localhost) must pass the guard and reach the SDK client.""" from local_deep_research.llm.providers.openai_base import ( OpenAICompatibleProvider, ) captured = {} class _FakeModels: def list(self): return Mock(data=[]) class _FakeClient: def __init__(self, api_key=None, base_url=None): captured["base_url"] = base_url self.models = _FakeModels() import openai monkeypatch.setattr(openai, "OpenAI", _FakeClient) class _TestProvider(OpenAICompatibleProvider): provider_name = "TestListProvider" url_setting = "llm.test.url" api_key_setting = None default_base_url = "https://api.example.com/v1" @classmethod def requires_auth_for_models(cls): return False result = _TestProvider.list_models_for_api( api_key="test-key", base_url="http://localhost:1234/v1", ) assert result == [] assert captured["base_url"] == "http://localhost:1234/v1", ( "localhost base_url should reach the SDK client unchanged" ) class TestOpenAIProviderApiBaseSsrf: """OpenAIProvider overrides create_llm without calling super and has url_setting = None, so the base-class guard never runs. It gates llm.openai.api_base directly and lets ValueError propagate (fail-fast, matching ollama.create_llm).""" def test_create_llm_rejects_metadata_api_base(self, monkeypatch): """A blocked llm.openai.api_base must raise ValueError before the ChatOpenAI constructor runs.""" from local_deep_research.llm.providers.implementations.openai import ( OpenAIProvider, ) mock_ctor = MagicMock(return_value=Mock()) monkeypatch.setattr( "local_deep_research.llm.providers.implementations.openai.ChatOpenAI", mock_ctor, ) snapshot = { "llm.openai.api_key": "sk-test-key", "llm.openai.api_base": "http://169.254.169.254/v1", } with pytest.raises(ValueError, match="failed SSRF validation"): OpenAIProvider.create_llm( model_name="gpt-4o-mini", settings_snapshot=snapshot ) assert mock_ctor.call_count == 0, ( "ChatOpenAI was instantiated despite SSRF guard raising" ) def test_create_llm_allows_localhost_api_base(self, monkeypatch): """A legitimate local api_base (proxy / gateway on localhost) must pass the guard and reach the ChatOpenAI constructor.""" from local_deep_research.llm.providers.implementations.openai import ( OpenAIProvider, ) captured = {} def _fake_ctor(**kwargs): captured.update(kwargs) return Mock() monkeypatch.setattr( "local_deep_research.llm.providers.implementations.openai.ChatOpenAI", _fake_ctor, ) snapshot = { "llm.openai.api_key": "sk-test-key", "llm.openai.api_base": "http://localhost:8080/v1", } OpenAIProvider.create_llm( model_name="gpt-4o-mini", settings_snapshot=snapshot ) assert captured["openai_api_base"] == "http://localhost:8080/v1", ( "localhost api_base should pass the guard and reach ChatOpenAI" ) class TestPublicApi: """assert_base_url_safe and redact_url_for_log are exported from security/__init__.py for downstream importers.""" def test_assert_base_url_safe_re_exported(self): from local_deep_research.security import assert_base_url_safe assert callable(assert_base_url_safe) def test_redact_url_for_log_re_exported(self): from local_deep_research.security import redact_url_for_log assert callable(redact_url_for_log) class TestCustomAnthropicEndpointSsrf: """CustomAnthropicEndpointProvider gates its operator-configured base_url (``llm.anthropic_endpoint.url``) through the SSRF guard in the inherited AnthropicProvider.create_llm, and in list_models_for_api. The SDK is ChatAnthropic / the anthropic SDK, both of which use their own httpx client that bypasses safe_requests — so the guard is load-bearing.""" def test_create_llm_rejects_aws_metadata(self): from local_deep_research.llm.providers.implementations.custom_anthropic_endpoint import ( CustomAnthropicEndpointProvider, ) snapshot = {"llm.anthropic_endpoint.url": "http://169.254.169.254/"} with pytest.raises(ValueError, match="failed SSRF validation"): CustomAnthropicEndpointProvider.create_llm( model_name="claude-3-5-sonnet", settings_snapshot=snapshot ) def test_create_llm_validate_runs_before_chatanthropic_constructor( self, monkeypatch ): """Make assert_base_url_safe raise; ChatAnthropic must never be instantiated. Both names are bound in anthropic.py (the parent's create_llm), so patch them there.""" from local_deep_research.llm.providers.implementations.custom_anthropic_endpoint import ( CustomAnthropicEndpointProvider, ) mock_ctor = MagicMock(return_value=Mock()) monkeypatch.setattr( "local_deep_research.llm.providers.implementations.anthropic.ChatAnthropic", mock_ctor, ) monkeypatch.setattr( "local_deep_research.llm.providers.implementations.anthropic.assert_base_url_safe", Mock(side_effect=ValueError("simulated SSRF rejection")), ) with pytest.raises(ValueError): CustomAnthropicEndpointProvider.create_llm( model_name="claude-3-5-sonnet", settings_snapshot={ "llm.anthropic_endpoint.url": "http://example.com/" }, ) assert mock_ctor.call_count == 0, ( "ChatAnthropic was instantiated despite SSRF guard raising — " "ordering regression" ) def test_list_models_for_api_returns_empty_on_metadata_url(self): from local_deep_research.llm.providers.implementations.custom_anthropic_endpoint import ( CustomAnthropicEndpointProvider, ) result = CustomAnthropicEndpointProvider.list_models_for_api( api_key="test", base_url="http://169.254.169.254/" ) assert result == []