diff --git a/api/changelog.d/lighthouse-provider-base-url.security.md b/api/changelog.d/lighthouse-provider-base-url.security.md new file mode 100644 index 0000000000..6e70e9fbdd --- /dev/null +++ b/api/changelog.d/lighthouse-provider-base-url.security.md @@ -0,0 +1 @@ +OpenAI-compatible Lighthouse provider base URLs are restricted before connection checks diff --git a/api/src/backend/api/tests/test_validators.py b/api/src/backend/api/tests/test_validators.py new file mode 100644 index 0000000000..2b6c384cf0 --- /dev/null +++ b/api/src/backend/api/tests/test_validators.py @@ -0,0 +1,150 @@ +import socket + +import pytest +from api.validators import validate_lighthouse_openai_compatible_base_url +from django.core.exceptions import ValidationError + + +def test_lighthouse_base_url_rejects_http_scheme(): + with pytest.raises(ValidationError, match="HTTPS"): + validate_lighthouse_openai_compatible_base_url( + "http://openrouter.ai/api/v1", + resolve_dns=False, + ) + + +@pytest.mark.parametrize( + "base_url", + [ + "https://openrouter.ai:0/api/v1", + "https://openrouter.ai:-1/api/v1", + "https://openrouter.ai:65536/api/v1", + "https://openrouter.ai:invalid/api/v1", + ], +) +def test_lighthouse_base_url_rejects_invalid_port(base_url): + with pytest.raises(ValidationError, match="port is invalid"): + validate_lighthouse_openai_compatible_base_url( + base_url, + resolve_dns=False, + ) + + +@pytest.mark.parametrize("port", [1, 65535]) +def test_lighthouse_base_url_accepts_valid_port_boundaries(port): + assert ( + validate_lighthouse_openai_compatible_base_url( + f"https://openrouter.ai:{port}/api/v1", + resolve_dns=False, + ) + is None + ) + + +def test_lighthouse_base_url_rejects_localhost(): + with pytest.raises(ValidationError, match="external public endpoint"): + validate_lighthouse_openai_compatible_base_url( + "https://localhost/v1", + resolve_dns=False, + ) + + +@pytest.mark.parametrize("ip_address", ["10.0.0.1", "172.16.0.1", "192.168.1.1"]) +def test_lighthouse_base_url_rejects_private_ip_literal(ip_address): + with pytest.raises(ValidationError, match="external public endpoint"): + validate_lighthouse_openai_compatible_base_url( + f"https://{ip_address}/v1", + resolve_dns=False, + ) + + +def test_lighthouse_base_url_rejects_metadata_ip_literal(): + with pytest.raises(ValidationError, match="external public endpoint"): + validate_lighthouse_openai_compatible_base_url( + "https://169.254.169.254/latest/meta-data", + resolve_dns=False, + ) + + +@pytest.mark.parametrize( + "base_url", + [ + "https://[::ffff:169.254.169.254]/v1", + "https://[64:ff9b::a9fe:a9fe]/v1", + "https://[2002:a9fe:a9fe::]/v1", + ], +) +def test_lighthouse_base_url_rejects_embedded_non_global_ip(base_url): + with pytest.raises(ValidationError, match="external public endpoint"): + validate_lighthouse_openai_compatible_base_url( + base_url, + resolve_dns=False, + ) + + +@pytest.mark.parametrize( + "base_url", + [ + "https://[::ffff:93.184.216.34]/v1", + "https://[64:ff9b::5db8:d822]/v1", + "https://[2002:5db8:d822::]/v1", + ], +) +def test_lighthouse_base_url_accepts_embedded_public_ip(base_url): + assert ( + validate_lighthouse_openai_compatible_base_url( + base_url, + resolve_dns=False, + ) + is None + ) + + +def test_lighthouse_base_url_accepts_hostname_without_dns_resolution(): + assert ( + validate_lighthouse_openai_compatible_base_url( + "https://openrouter.ai/api/v1", + resolve_dns=False, + ) + is None + ) + + +def test_lighthouse_base_url_rejects_post_dns_internal_address(monkeypatch): + def resolve_to_metadata(*_args, **_kwargs): + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("169.254.169.254", 443), + ) + ] + + monkeypatch.setattr("api.validators.socket.getaddrinfo", resolve_to_metadata) + + with pytest.raises(ValidationError, match="external public endpoint"): + validate_lighthouse_openai_compatible_base_url( + "https://metadata.example.test/v1" + ) + + +def test_lighthouse_base_url_accepts_public_resolved_address(monkeypatch): + def resolve_to_public(*_args, **_kwargs): + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", 443), + ) + ] + + monkeypatch.setattr("api.validators.socket.getaddrinfo", resolve_to_public) + + assert ( + validate_lighthouse_openai_compatible_base_url("https://openrouter.ai/api/v1") + is None + ) diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 4fdb023955..be52393dfa 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -17308,6 +17308,76 @@ class TestLighthouseProviderConfigViewSet: error_detail = str(resp.json()).lower() assert "base_url" in error_detail + @pytest.mark.parametrize( + "base_url", + [ + "https://127.0.0.1/v1", + "https://169.254.169.254/latest/meta-data", + ], + ) + def test_openai_compatible_rejects_internal_base_url_on_create( + self, authenticated_client, base_url + ): + payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "openai_compatible", + "base_url": base_url, + "credentials": {"api_key": "compat-key"}, + }, + } + } + + resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "base_url" in str(resp.json()).lower() + + def test_openai_compatible_rejects_internal_base_url_on_update( + self, authenticated_client + ): + create_payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "openai_compatible", + "base_url": "https://openrouter.ai/api/v1", + "credentials": {"api_key": "compat-key-123"}, + }, + } + } + create_resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=create_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert create_resp.status_code == status.HTTP_201_CREATED + provider_id = create_resp.json()["data"]["id"] + + patch_payload = { + "data": { + "type": "lighthouse-providers", + "id": provider_id, + "attributes": { + "base_url": "https://169.254.169.254/latest/meta-data", + }, + } + } + + patch_resp = authenticated_client.patch( + reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}), + data=patch_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + + assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST + assert "base_url" in str(patch_resp.json()).lower() + def test_openai_compatible_invalid_credentials(self, authenticated_client): payload = { "data": { diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index f650a1368a..0f08c8f4ba 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -57,6 +57,7 @@ from api.v1.serializer_utils.lighthouse import ( ) from api.v1.serializer_utils.processors import ProcessorConfigField from api.v1.serializer_utils.providers import ProviderSecretField +from api.validators import validate_lighthouse_openai_compatible_base_url from django.conf import settings from django.contrib.auth import authenticate from django.contrib.auth.models import update_last_login @@ -80,6 +81,21 @@ from rest_framework_simplejwt.utils import get_md5_hash_password # Base +def _validate_lighthouse_base_url_without_dns(base_url: str) -> None: + try: + validate_lighthouse_openai_compatible_base_url(base_url, resolve_dns=False) + except DjangoValidationError as error: + raise ValidationError({"base_url": error.messages[0]}) from error + + +def _reraise_lighthouse_credentials_errors(error: ValidationError) -> None: + details = error.detail.copy() + for key, value in details.items(): + error.detail[f"credentials/{key}"] = value + del error.detail[key] + raise error + + class BaseModelSerializerV1(serializers.ModelSerializer): def get_root_meta(self, _resource, _many): return {"version": "v1"} @@ -3645,11 +3661,7 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize raise_exception=True ) except ValidationError as e: - details = e.detail.copy() - for key, value in details.items(): - e.detail[f"credentials/{key}"] = value - del e.detail[key] - raise e + _reraise_lighthouse_credentials_errors(e) elif ( provider_type == LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK ): @@ -3658,27 +3670,20 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize raise_exception=True ) except ValidationError as e: - details = e.detail.copy() - for key, value in details.items(): - e.detail[f"credentials/{key}"] = value - del e.detail[key] - raise e + _reraise_lighthouse_credentials_errors(e) elif ( provider_type == LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE ): if not base_url: raise ValidationError({"base_url": "Base URL is required."}) + _validate_lighthouse_base_url_without_dns(base_url) try: OpenAICompatibleCredentialsSerializer(data=credentials).is_valid( raise_exception=True ) except ValidationError as e: - details = e.detail.copy() - for key, value in details.items(): - e.detail[f"credentials/{key}"] = value - del e.detail[key] - raise e + _reraise_lighthouse_credentials_errors(e) return super().validate(attrs) @@ -3741,11 +3746,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer): raise_exception=True ) except ValidationError as e: - details = e.detail.copy() - for key, value in details.items(): - e.detail[f"credentials/{key}"] = value - del e.detail[key] - raise e + _reraise_lighthouse_credentials_errors(e) elif ( credentials is not None and provider_type @@ -3769,11 +3770,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer): raise_exception=True ) except ValidationError as e: - details = e.detail.copy() - for key, value in details.items(): - e.detail[f"credentials/{key}"] = value - del e.detail[key] - raise e + _reraise_lighthouse_credentials_errors(e) # Then enforce invariants about not changing the auth method # If the existing config uses an API key, forbid introducing access keys. @@ -3800,24 +3797,23 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer): } ) elif ( - credentials is not None - and provider_type + provider_type == LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE ): - if base_url is None: - pass - elif not base_url: + effective_base_url = ( + base_url if "base_url" in attrs else getattr(self.instance, "base_url") + ) + if not effective_base_url: raise ValidationError({"base_url": "Base URL cannot be empty."}) - try: - OpenAICompatibleCredentialsSerializer(data=credentials).is_valid( - raise_exception=True - ) - except ValidationError as e: - details = e.detail.copy() - for key, value in details.items(): - e.detail[f"credentials/{key}"] = value - del e.detail[key] - raise e + if "base_url" in attrs: + _validate_lighthouse_base_url_without_dns(effective_base_url) + if credentials is not None: + try: + OpenAICompatibleCredentialsSerializer(data=credentials).is_valid( + raise_exception=True + ) + except ValidationError as e: + _reraise_lighthouse_credentials_errors(e) return super().validate(attrs) diff --git a/api/src/backend/api/validators.py b/api/src/backend/api/validators.py index 543406f202..0bc45fb48b 100644 --- a/api/src/backend/api/validators.py +++ b/api/src/backend/api/validators.py @@ -1,14 +1,140 @@ +import ipaddress +import socket import string +from urllib.parse import urlparse from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ +LIGHTHOUSE_OPENAI_COMPATIBLE_ALLOWED_SCHEMES = frozenset({"https"}) +LIGHTHOUSE_NAT64_WELL_KNOWN_PREFIX = ipaddress.IPv6Network("64:ff9b::/96") +LIGHTHOUSE_BLOCKED_METADATA_HOSTS = frozenset( + { + "169.254.169.254", + "169.254.170.2", + "fd00:ec2::254", + "localhost", + "metadata.google.internal", + } +) + + +def _normalize_hostname(hostname: str) -> str: + return hostname.rstrip(".").lower() + + +def _validate_lighthouse_public_ip(address: str) -> None: + ip_address = ipaddress.ip_address(address) + if isinstance(ip_address, ipaddress.IPv6Address): + # Classify transition addresses by their effective IPv4 destination. + embedded_ip_address = ip_address.ipv4_mapped or ip_address.sixtofour + if ( + embedded_ip_address is None + and ip_address in LIGHTHOUSE_NAT64_WELL_KNOWN_PREFIX + ): + embedded_ip_address = ipaddress.IPv4Address(int(ip_address) & 0xFFFFFFFF) + if embedded_ip_address is not None: + ip_address = embedded_ip_address + if not ip_address.is_global: + raise ValidationError( + _("Base URL must use an external public endpoint."), + code="lighthouse_base_url_not_public", + ) + + +def resolve_lighthouse_openai_compatible_host( + hostname: str, + port: int, + *, + resolve_dns: bool = True, +) -> tuple[str, ...]: + """Return public IP addresses that are safe for Lighthouse outbound use.""" + hostname = _normalize_hostname(hostname) + if hostname in LIGHTHOUSE_BLOCKED_METADATA_HOSTS or hostname.endswith(".localhost"): + raise ValidationError( + _("Base URL must use an external public endpoint."), + code="lighthouse_base_url_blocked_host", + ) + + try: + _validate_lighthouse_public_ip(hostname) + except ValueError: + if not resolve_dns: + return () + else: + return (hostname,) + + try: + resolved_addresses = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM) + except socket.gaierror as error: + raise ValidationError( + _("Base URL host could not be resolved."), + code="lighthouse_base_url_resolution_failed", + ) from error + + if not resolved_addresses: + raise ValidationError( + _("Base URL host could not be resolved."), + code="lighthouse_base_url_resolution_failed", + ) + + public_addresses: list[str] = [] + for resolved_address in resolved_addresses: + socket_address = resolved_address[4] + resolved_ip_address = socket_address[0] + _validate_lighthouse_public_ip(resolved_ip_address) + if resolved_ip_address not in public_addresses: + public_addresses.append(resolved_ip_address) + + return tuple(public_addresses) + + +def validate_lighthouse_openai_compatible_base_url( + base_url: str, + *, + resolve_dns: bool = True, +) -> None: + """Validate an OpenAI-compatible Lighthouse base URL before outbound use.""" + parsed = urlparse(str(base_url)) + if parsed.scheme.lower() not in LIGHTHOUSE_OPENAI_COMPATIBLE_ALLOWED_SCHEMES: + raise ValidationError( + _("Base URL must use HTTPS."), + code="lighthouse_base_url_invalid_scheme", + ) + + if not parsed.hostname: + raise ValidationError( + _("Base URL must include a host."), + code="lighthouse_base_url_missing_host", + ) + + try: + port = parsed.port + except ValueError as error: + raise ValidationError( + _("Base URL port is invalid."), + code="lighthouse_base_url_invalid_port", + ) from error + + if port is not None and not 1 <= port <= 65535: + raise ValidationError( + _("Base URL port is invalid."), + code="lighthouse_base_url_invalid_port", + ) + + resolve_lighthouse_openai_compatible_host( + parsed.hostname, + port or 443, + resolve_dns=resolve_dns, + ) + class MaximumLengthValidator: def __init__(self, max_length=72): self.max_length = max_length def validate(self, password, user=None): + del user if len(password) > self.max_length: raise ValidationError( _( @@ -31,6 +157,7 @@ class SpecialCharactersValidator: self.min_special_characters = min_special_characters def validate(self, password, user=None): + del user if ( sum(1 for char in password if char in self.special_characters) < self.min_special_characters @@ -55,6 +182,7 @@ class UppercaseValidator: self.min_uppercase = min_uppercase def validate(self, password, user=None): + del user if sum(1 for char in password if char.isupper()) < self.min_uppercase: raise ValidationError( _( @@ -75,6 +203,7 @@ class LowercaseValidator: self.min_lowercase = min_lowercase def validate(self, password, user=None): + del user if sum(1 for char in password if char.islower()) < self.min_lowercase: raise ValidationError( _( @@ -95,6 +224,7 @@ class NumericValidator: self.min_numeric = min_numeric def validate(self, password, user=None): + del user if sum(1 for char in password if char.isdigit()) < self.min_numeric: raise ValidationError( _( diff --git a/api/src/backend/tasks/jobs/lighthouse_providers.py b/api/src/backend/tasks/jobs/lighthouse_providers.py index 0f28725e01..1eec1d3c6a 100644 --- a/api/src/backend/tasks/jobs/lighthouse_providers.py +++ b/api/src/backend/tasks/jobs/lighthouse_providers.py @@ -1,6 +1,15 @@ +import ssl +from collections.abc import Iterable + import boto3 +import httpcore +import httpx import openai from api.models import LighthouseProviderConfiguration, LighthouseProviderModels +from api.validators import ( + resolve_lighthouse_openai_compatible_host, + validate_lighthouse_openai_compatible_base_url, +) from botocore import UNSIGNED from botocore.config import Config from botocore.exceptions import BotoCoreError, ClientError @@ -43,6 +52,90 @@ EXCLUDED_OPENAI_MODEL_SUBSTRINGS = ( "-instruct", # Legacy instruct models (gpt-3.5-turbo-instruct, etc.) ) +OPENAI_COMPATIBLE_AUTHENTICATION_ERROR = "API key is invalid or missing" +OPENAI_COMPATIBLE_CONNECTION_ERROR = "Provider connection failed" + + +class _OpenAICompatibleProviderError(Exception): + """Sanitized OpenAI-compatible provider error safe for task results.""" + + +def _sanitize_openai_compatible_error(error: Exception) -> str: + status_code = getattr(error, "status_code", None) + if status_code is None: + response = getattr(error, "response", None) + status_code = getattr(response, "status_code", None) + + if status_code == 401: + return OPENAI_COMPATIBLE_AUTHENTICATION_ERROR + return OPENAI_COMPATIBLE_CONNECTION_ERROR + + +class _LighthouseOpenAICompatibleNetworkBackend(httpcore.SyncBackend): + """Validate and pin DNS results immediately before TCP connections.""" + + def connect_tcp( + self, + host: str, + port: int, + timeout: float | None = None, + local_address: str | None = None, + socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None, + ) -> httpcore.NetworkStream: + resolved_addresses = resolve_lighthouse_openai_compatible_host(host, port) + last_error: httpcore.ConnectError | httpcore.ConnectTimeout | None = None + + for address in resolved_addresses: + try: + return super().connect_tcp( + address, + port, + timeout=timeout, + local_address=local_address, + socket_options=socket_options, + ) + except (httpcore.ConnectError, httpcore.ConnectTimeout) as error: + last_error = error + + if last_error: + raise last_error + raise httpcore.ConnectError("No resolved addresses are available") + + +class _LighthouseOpenAICompatibleHTTPTransport(httpx.HTTPTransport): + """HTTP transport that connects only to validated public IP addresses.""" + + def __init__(self) -> None: + self._pool = httpcore.ConnectionPool( + ssl_context=ssl.create_default_context(), + network_backend=_LighthouseOpenAICompatibleNetworkBackend(), + ) + + +def _create_openai_compatible_http_client() -> httpx.Client: + """Create the restricted HTTP client used for OpenAI-compatible providers.""" + return httpx.Client( + follow_redirects=False, + trust_env=False, + transport=_LighthouseOpenAICompatibleHTTPTransport(), + ) + + +def _list_openai_compatible_models(base_url: str, api_key: str): + validate_lighthouse_openai_compatible_base_url(base_url) + try: + with _create_openai_compatible_http_client() as http_client: + client = openai.OpenAI( + api_key=api_key, + base_url=base_url, + http_client=http_client, + ) + return client.models.list() + except Exception as error: + raise _OpenAICompatibleProviderError( + _sanitize_openai_compatible_error(error) + ) from error + def _extract_error_message(e: Exception) -> str: """ @@ -114,6 +207,7 @@ def _extract_openai_compatible_params( return None if not isinstance(base_url, str) or not base_url: return None + validate_lighthouse_openai_compatible_base_url(base_url, resolve_dns=False) return {"base_url": base_url, "api_key": api_key} @@ -285,13 +379,7 @@ def check_lighthouse_provider_connection(provider_config_id: str) -> dict: "error": "Base URL or API key is invalid or missing", } - # Test connection using OpenAI SDK with custom base_url - # Note: base_url should include version (e.g., https://openrouter.ai/api/v1) - client = openai.OpenAI( - api_key=params["api_key"], - base_url=params["base_url"], - ) - _ = client.models.list() + _ = _list_openai_compatible_models(params["base_url"], params["api_key"]) else: return {"connected": False, "error": "Unsupported provider type"} @@ -361,8 +449,7 @@ def _fetch_openai_compatible_models(base_url: str, api_key: str) -> dict[str, st Note: base_url should include version (e.g., https://openrouter.ai/api/v1) """ - client = openai.OpenAI(api_key=api_key, base_url=base_url) - models = client.models.list() + models = _list_openai_compatible_models(base_url, api_key) available_models: dict[str, str] = {} for model in models.data: diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index 631b91bf6b..6622ab10a5 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -3,6 +3,7 @@ from contextlib import contextmanager from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch +import httpx import openai import pytest from api.models import ( @@ -20,6 +21,7 @@ from django_celery_results.models import TaskResult from tasks.jobs.lighthouse_providers import ( _create_bedrock_client, _extract_bedrock_credentials, + _LighthouseOpenAICompatibleNetworkBackend, ) from tasks.tasks import ( DJANGO_TMP_OUTPUT_DIRECTORY, @@ -1566,7 +1568,7 @@ class TestCheckLighthouseProviderConnectionTask: ( LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, {"api_key": "sk-test123"}, - "https://openrouter.ai/api/v1", + "https://93.184.216.34/api/v1", {"connected": True, "error": None}, ), ( @@ -1641,7 +1643,7 @@ class TestCheckLighthouseProviderConnectionTask: ( LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, {"api_key": "sk-invalid"}, - "https://openrouter.ai/api/v1", + "https://93.184.216.34/api/v1", openai.APIConnectionError(request=MagicMock()), ), ( @@ -1755,6 +1757,166 @@ class TestCheckLighthouseProviderConnectionTask: provider_cfg.refresh_from_db() assert provider_cfg.is_active is False + def test_openai_compatible_connection_rejects_metadata_base_url_without_request( + self, tenants_fixture + ): + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, + base_url="https://169.254.169.254/latest/meta-data", + is_active=True, + ) + provider_cfg.credentials_decoded = {"api_key": "compatible-key"} + provider_cfg.save() + + with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai: + eager_result = check_lighthouse_provider_connection_task.apply( + kwargs={ + "provider_config_id": str(provider_cfg.id), + "tenant_id": str(tenants_fixture[0].id), + } + ) + + assert eager_result.successful() + result = eager_result.result + assert result["connected"] is False + assert "base url" in result["error"].lower() + mock_openai.assert_not_called() + provider_cfg.refresh_from_db() + assert provider_cfg.is_active is False + + def test_openai_compatible_connection_disables_redirects(self, tenants_fixture): + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, + base_url="https://93.184.216.34/api/v1", + is_active=False, + ) + provider_cfg.credentials_decoded = {"api_key": "compatible-key"} + provider_cfg.save() + + with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai: + mock_client = MagicMock() + mock_client.models.list.return_value = MagicMock() + mock_openai.return_value = mock_client + + eager_result = check_lighthouse_provider_connection_task.apply( + kwargs={ + "provider_config_id": str(provider_cfg.id), + "tenant_id": str(tenants_fixture[0].id), + } + ) + + assert eager_result.successful() + result = eager_result.result + assert result == {"connected": True, "error": None} + http_client = mock_openai.call_args.kwargs["http_client"] + assert http_client.follow_redirects is False + assert http_client.trust_env is False + + def test_openai_compatible_connection_masks_remote_http_error( + self, tenants_fixture + ): + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, + base_url="https://93.184.216.34/api/v1", + is_active=True, + ) + provider_cfg.credentials_decoded = {"api_key": "compatible-key"} + provider_cfg.save() + remote_body = "

remote 404 body

" + response = httpx.Response( + 404, + request=httpx.Request("GET", "https://provider.example/v1/models"), + ) + + with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai: + mock_client = MagicMock() + mock_client.models.list.side_effect = openai.NotFoundError( + remote_body, + response=response, + body=remote_body, + ) + mock_openai.return_value = mock_client + + eager_result = check_lighthouse_provider_connection_task.apply( + kwargs={ + "provider_config_id": str(provider_cfg.id), + "tenant_id": str(tenants_fixture[0].id), + } + ) + + assert eager_result.successful() + result = eager_result.result + assert result == {"connected": False, "error": "Provider connection failed"} + assert remote_body not in result["error"] + provider_cfg.refresh_from_db() + assert provider_cfg.is_active is False + + def test_openai_compatible_connection_masks_remote_auth_error( + self, tenants_fixture + ): + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, + base_url="https://93.184.216.34/api/v1", + is_active=True, + ) + provider_cfg.credentials_decoded = {"api_key": "compatible-key"} + provider_cfg.save() + remote_body = {"error": {"message": "remote auth detail"}} + response = httpx.Response( + 401, + request=httpx.Request("GET", "https://provider.example/v1/models"), + ) + + with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai: + mock_client = MagicMock() + mock_client.models.list.side_effect = openai.AuthenticationError( + "Unauthorized", + response=response, + body=remote_body, + ) + mock_openai.return_value = mock_client + + eager_result = check_lighthouse_provider_connection_task.apply( + kwargs={ + "provider_config_id": str(provider_cfg.id), + "tenant_id": str(tenants_fixture[0].id), + } + ) + + assert eager_result.successful() + result = eager_result.result + assert result == {"connected": False, "error": "API key is invalid or missing"} + assert "remote auth detail" not in result["error"] + provider_cfg.refresh_from_db() + assert provider_cfg.is_active is False + + def test_openai_compatible_network_backend_uses_validated_ip(self, monkeypatch): + backend = _LighthouseOpenAICompatibleNetworkBackend() + stream = MagicMock() + + def resolve_to_public_ip(host, port): + del host, port + return ("93.184.216.34",) + + monkeypatch.setattr( + "tasks.jobs.lighthouse_providers.resolve_lighthouse_openai_compatible_host", + resolve_to_public_ip, + ) + + with patch( + "tasks.jobs.lighthouse_providers.httpcore.SyncBackend.connect_tcp", + return_value=stream, + ) as mock_connect_tcp: + result = backend.connect_tcp("provider.example", 443, timeout=1.0) + + assert result is stream + assert mock_connect_tcp.call_args.args[:2] == ("93.184.216.34", 443) + assert mock_connect_tcp.call_args.kwargs["timeout"] == 1.0 + def test_check_connection_provider_does_not_exist(self, tenants_fixture): """Test that checking non-existent provider raises DoesNotExist.""" non_existent_id = str(uuid.uuid4()) @@ -1784,7 +1946,7 @@ class TestRefreshLighthouseProviderModelsTask: ( LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, {"api_key": "sk-test123"}, - "https://openrouter.ai/api/v1", + "https://93.184.216.34/api/v1", {"model-1": "Model One", "model-2": "Model Two"}, 2, ), @@ -1864,6 +2026,106 @@ class TestRefreshLighthouseProviderModelsTask: == expected_count ) + def test_refresh_models_rejects_metadata_base_url_without_request( + self, tenants_fixture + ): + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, + base_url="https://169.254.169.254/latest/meta-data", + is_active=True, + ) + provider_cfg.credentials_decoded = {"api_key": "compatible-key"} + provider_cfg.save() + + with patch( + "tasks.jobs.lighthouse_providers._fetch_openai_compatible_models" + ) as mock_fetch: + eager_result = refresh_lighthouse_provider_models_task.apply( + kwargs={ + "provider_config_id": str(provider_cfg.id), + "tenant_id": str(tenants_fixture[0].id), + } + ) + + assert eager_result.successful() + result = eager_result.result + assert result["created"] == 0 + assert result["updated"] == 0 + assert result["deleted"] == 0 + assert "base url" in result["error"].lower() + mock_fetch.assert_not_called() + + def test_refresh_models_disables_redirects(self, tenants_fixture): + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, + base_url="https://93.184.216.34/api/v1", + is_active=True, + ) + provider_cfg.credentials_decoded = {"api_key": "compatible-key"} + provider_cfg.save() + + with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai: + mock_client = MagicMock() + mock_client.models.list.return_value = MagicMock(data=[]) + mock_openai.return_value = mock_client + + eager_result = refresh_lighthouse_provider_models_task.apply( + kwargs={ + "provider_config_id": str(provider_cfg.id), + "tenant_id": str(tenants_fixture[0].id), + } + ) + + assert eager_result.successful() + result = eager_result.result + assert result["created"] == 0 + assert result["updated"] == 0 + assert result["deleted"] == 0 + http_client = mock_openai.call_args.kwargs["http_client"] + assert http_client.follow_redirects is False + assert http_client.trust_env is False + + def test_refresh_models_masks_remote_http_error(self, tenants_fixture): + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, + base_url="https://93.184.216.34/api/v1", + is_active=True, + ) + provider_cfg.credentials_decoded = {"api_key": "compatible-key"} + provider_cfg.save() + remote_body = "

remote 404 body

" + response = httpx.Response( + 404, + request=httpx.Request("GET", "https://provider.example/v1/models"), + ) + + with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai: + mock_client = MagicMock() + mock_client.models.list.side_effect = openai.NotFoundError( + remote_body, + response=response, + body=remote_body, + ) + mock_openai.return_value = mock_client + + eager_result = refresh_lighthouse_provider_models_task.apply( + kwargs={ + "provider_config_id": str(provider_cfg.id), + "tenant_id": str(tenants_fixture[0].id), + } + ) + + assert eager_result.successful() + result = eager_result.result + assert result["created"] == 0 + assert result["updated"] == 0 + assert result["deleted"] == 0 + assert result["error"] == "Provider connection failed" + assert remote_body not in result["error"] + def test_refresh_models_mixed_operations(self, tenants_fixture): """Test mixed create, update, and delete operations.""" # Create provider configuration