Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

500 lines
18 KiB
Python

"""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.<provider>.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 == []