diff --git a/api/changelog.d/lighthouse-openai-compatible-allowed-hosts.security.md b/api/changelog.d/lighthouse-openai-compatible-allowed-hosts.security.md new file mode 100644 index 0000000000..a015cd090b --- /dev/null +++ b/api/changelog.d/lighthouse-openai-compatible-allowed-hosts.security.md @@ -0,0 +1 @@ +`LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS` environment variable to allow internal hosts as OpenAI-compatible Lighthouse AI base URLs diff --git a/api/src/backend/api/tests/test_validators.py b/api/src/backend/api/tests/test_validators.py index 2b6c384cf0..a431a659c7 100644 --- a/api/src/backend/api/tests/test_validators.py +++ b/api/src/backend/api/tests/test_validators.py @@ -1,8 +1,12 @@ import socket import pytest -from api.validators import validate_lighthouse_openai_compatible_base_url +from api.validators import ( + resolve_lighthouse_openai_compatible_host, + validate_lighthouse_openai_compatible_base_url, +) from django.core.exceptions import ValidationError +from django.test import override_settings def test_lighthouse_base_url_rejects_http_scheme(): @@ -148,3 +152,95 @@ def test_lighthouse_base_url_accepts_public_resolved_address(monkeypatch): validate_lighthouse_openai_compatible_base_url("https://openrouter.ai/api/v1") is None ) + + +@override_settings( + LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"] +) +def test_lighthouse_base_url_accepts_allowlisted_host_without_resolution(monkeypatch): + def fail_resolution(*_args, **_kwargs): + raise AssertionError("allowlisted hosts must not be resolved") + + monkeypatch.setattr("api.validators.socket.getaddrinfo", fail_resolution) + + assert ( + validate_lighthouse_openai_compatible_base_url( + "https://custom-openai.internal/v1" + ) + is None + ) + + +@override_settings( + LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"] +) +def test_lighthouse_resolve_returns_allowlisted_hostname_unpinned(): + assert resolve_lighthouse_openai_compatible_host( + "Custom-OpenAI.internal.", 443 + ) == ("custom-openai.internal",) + + +@override_settings(LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["localhost"]) +def test_lighthouse_base_url_accepts_allowlisted_blocked_host(): + assert ( + validate_lighthouse_openai_compatible_base_url( + "https://localhost/v1", + resolve_dns=False, + ) + is None + ) + + +@override_settings(LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["10.0.0.1"]) +def test_lighthouse_base_url_accepts_allowlisted_private_ip_literal(): + assert ( + validate_lighthouse_openai_compatible_base_url( + "https://10.0.0.1/v1", + resolve_dns=False, + ) + is None + ) + + +@override_settings( + LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=[" Custom-OpenAI.Internal. "] +) +def test_lighthouse_allowlist_entries_are_normalized(): + assert ( + validate_lighthouse_openai_compatible_base_url( + "https://custom-openai.internal/v1", + resolve_dns=False, + ) + is None + ) + + +@override_settings( + LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"] +) +def test_lighthouse_base_url_rejects_host_not_in_allowlist(): + with pytest.raises(ValidationError, match="external public endpoint"): + validate_lighthouse_openai_compatible_base_url( + "https://localhost/v1", + resolve_dns=False, + ) + + +@override_settings(LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=[""]) +def test_lighthouse_allowlist_ignores_empty_entries(): + with pytest.raises(ValidationError, match="external public endpoint"): + validate_lighthouse_openai_compatible_base_url( + "https://localhost/v1", + resolve_dns=False, + ) + + +@override_settings( + LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"] +) +def test_lighthouse_base_url_allowlisted_host_still_requires_https(): + with pytest.raises(ValidationError, match="HTTPS"): + validate_lighthouse_openai_compatible_base_url( + "http://custom-openai.internal/v1", + resolve_dns=False, + ) diff --git a/api/src/backend/api/validators.py b/api/src/backend/api/validators.py index 0bc45fb48b..6ad7ccfe0f 100644 --- a/api/src/backend/api/validators.py +++ b/api/src/backend/api/validators.py @@ -3,6 +3,7 @@ import socket import string from urllib.parse import urlparse +from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ @@ -23,6 +24,14 @@ def _normalize_hostname(hostname: str) -> str: return hostname.rstrip(".").lower() +def _lighthouse_openai_compatible_allowed_hosts() -> frozenset[str]: + return frozenset( + _normalize_hostname(allowed_host.strip()) + for allowed_host in settings.LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS + if allowed_host and allowed_host.strip() + ) + + def _validate_lighthouse_public_ip(address: str) -> None: ip_address = ipaddress.ip_address(address) if isinstance(ip_address, ipaddress.IPv6Address): @@ -50,6 +59,12 @@ def resolve_lighthouse_openai_compatible_host( ) -> tuple[str, ...]: """Return public IP addresses that are safe for Lighthouse outbound use.""" hostname = _normalize_hostname(hostname) + if hostname in _lighthouse_openai_compatible_allowed_hosts(): + # Operator-allowlisted hosts skip the public-endpoint checks; returning + # the hostname makes the network backend connect through regular DNS + # resolution instead of pinned addresses. + return (hostname,) + if hostname in LIGHTHOUSE_BLOCKED_METADATA_HOSTS or hostname.endswith(".localhost"): raise ValidationError( _("Base URL must use an external public endpoint."), diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 63dab22c2c..23af0cc6d3 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -317,6 +317,15 @@ ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int( # Valid values: "neo4j" (default, OSS and local dev), "neptune" (hosted). ATTACK_PATHS_SINK_DATABASE = env.str("ATTACK_PATHS_SINK_DATABASE", default="neo4j") +# Lighthouse AI +# Comma-separated hostnames (or IP literals) that bypass the SSRF validation +# applied to OpenAI-compatible provider base URLs, so self-hosted deployments +# can point Lighthouse AI at internal endpoints. Empty by default: every base +# URL must resolve to a public endpoint. +LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS = env.list( + "LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS", default=[] +) + # Orphan task recovery feature flags. The master switch is OFF by default, so task # recovery is opt-in; enable it with DJANGO_TASK_RECOVERY_ENABLED=true. The per-group # toggles default to enabled, so once the master is on every group recovers unless a diff --git a/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx b/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx index cdc6e84b9a..b1cb918de7 100644 --- a/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx +++ b/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx @@ -128,6 +128,25 @@ To connect a provider: 3. Configure in Lighthouse AI: - **API Key**: OpenRouter API key - **Base URL**: `https://openrouter.ai/api/v1` + + ### Base URL Validation + + To prevent server-side request forgery (SSRF), Prowler API validates the base URL before connecting to it: + + - The URL must use HTTPS. + - The host must resolve to a public IP address. Private, loopback, link-local, and cloud metadata addresses are rejected. + + + This validation can break configurations that point to internal endpoints, such as a self-hosted Ollama server. This is intentional: it fixes a security issue where the Prowler API could be directed to internal services. Internal endpoints must now be allowed explicitly through `LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS`. + + + To allow internal endpoints, set a comma-separated list of hostnames or IP addresses in the Prowler API environment (for Docker Compose deployments, the shared `.env` file): + + ```bash + LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=custom-openai.internal,10.0.0.20 + ``` + + Hosts in this list skip the public-endpoint validation. HTTPS is still required, so the endpoint needs a certificate the Prowler API trusts.