mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
fix(api): harden Lighthouse provider base URLs (#11928)
Co-authored-by: Josema Camacho <josema@prowler.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
OpenAI-compatible Lighthouse provider base URLs are restricted before connection checks
|
||||
@@ -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
|
||||
)
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
_(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = "<!DOCTYPE HTML><p>remote 404 body</p>"
|
||||
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 = "<!DOCTYPE HTML><p>remote 404 body</p>"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user