diff --git a/prowler/changelog.d/openstack-auth-url-ssrf.security.md b/prowler/changelog.d/openstack-auth-url-ssrf.security.md new file mode 100644 index 0000000000..cd9907f43c --- /dev/null +++ b/prowler/changelog.d/openstack-auth-url-ssrf.security.md @@ -0,0 +1 @@ +OpenStack connection test rejects an `auth_url` with a non-HTTP scheme or a host that resolves to a loopback, link-local or private address, so a tenant-supplied clouds.yaml can no longer make the worker probe internal services diff --git a/prowler/lib/network/__init__.py b/prowler/lib/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/network/ssrf.py b/prowler/lib/network/ssrf.py new file mode 100644 index 0000000000..30f5e9b861 --- /dev/null +++ b/prowler/lib/network/ssrf.py @@ -0,0 +1,142 @@ +"""Outbound URL validation for provider connection tests.""" + +from __future__ import annotations + +import ipaddress +import os +import re +import socket +from urllib.parse import urlparse + +from prowler.lib.logger import logger + +ALLOWED_PRIVATE_NETWORKS_ENV = "PROWLER_ALLOWED_PRIVATE_NETWORKS" + +_NON_PUBLIC_IP_PROPERTIES = ( + "is_private", + "is_loopback", + "is_link_local", + "is_multicast", + "is_reserved", + "is_unspecified", +) + +# scp-like git remotes (user@host:path) carry no scheme, so urlparse cannot read them +_SCP_LIKE_REMOTE = re.compile(r"^(?:[^@/]+@)?(?P[^:/]+):(?!//)") + +_NAT64_WELL_KNOWN_PREFIX = ipaddress.IPv6Network("64:ff9b::/96") + + +class OutboundURLNotAllowedError(Exception): + """A supplied URL points at a destination the worker must not reach.""" + + +def _parse_allowed_networks(raw: str | None) -> tuple: + if not raw or not raw.strip(): + return () + networks = [] + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + try: + networks.append(ipaddress.ip_network(entry, strict=False)) + except ValueError as error: + raise OutboundURLNotAllowedError( + f"Malformed entry {entry!r} in {ALLOWED_PRIVATE_NETWORKS_ENV}: {error}" + ) + return tuple(networks) + + +def allowed_private_networks() -> tuple: + """Operator-configured private networks the SSRF guard must not block.""" + networks = _parse_allowed_networks(os.environ.get(ALLOWED_PRIVATE_NETWORKS_ENV)) + if networks: + logger.warning( + f"{ALLOWED_PRIVATE_NETWORKS_ENV} is set — SSRF protection relaxed for private networks: " + + ", ".join(str(network) for network in networks) + ) + return networks + + +def _unwrap_ipv6(address: ipaddress._BaseAddress) -> ipaddress._BaseAddress: + if not isinstance(address, ipaddress.IPv6Address): + return address + embedded = address.ipv4_mapped or address.sixtofour + if embedded is None and address in _NAT64_WELL_KNOWN_PREFIX: + embedded = ipaddress.IPv4Address(int(address) & 0xFFFFFFFF) + return embedded or address + + +def _ip_is_non_public(address: str) -> bool: + try: + parsed = _unwrap_ipv6(ipaddress.ip_address(address)) + except ValueError: + return False + # is_global is the broad check; the properties stay because some multicast + # ranges report is_global and would otherwise slip through + if not parsed.is_global: + return True + return any(getattr(parsed, prop) for prop in _NON_PUBLIC_IP_PROPERTIES) + + +def _ip_is_allowlisted(address: str, networks: tuple) -> bool: + try: + parsed = ipaddress.ip_address(address) + except ValueError: + return False + return any( + parsed.version == network.version and parsed in network for network in networks + ) + + +def _resolve(host: str) -> set: + try: + return {sockaddr[0] for *_, sockaddr in socket.getaddrinfo(host, None)} + except socket.gaierror as error: + raise OutboundURLNotAllowedError(f"Could not resolve host {host!r}: {error}") + + +def extract_host(url: str) -> str: + """Host of a URL, accepting scp-like git remotes that carry no scheme.""" + scp_like = _SCP_LIKE_REMOTE.match(url) + if scp_like and "://" not in url: + return scp_like.group("host") + host = urlparse(url).hostname + if not host: + raise OutboundURLNotAllowedError(f"Could not read a host from URL {url!r}") + return host + + +def validate_outbound_host(host: str) -> None: + """Reject a host that is, or resolves to, a non-public address. + + Resolution happens here and again inside the client that connects, so a + hostile DNS server can still answer differently the second time. + """ + networks = allowed_private_networks() + + try: + ipaddress.ip_address(host) + except ValueError: + addresses = _resolve(host) + else: + addresses = {host} + + for address in addresses: + if _ip_is_non_public(address) and not _ip_is_allowlisted(address, networks): + raise OutboundURLNotAllowedError( + f"Host {host!r} resolves to non-public address {address} and cannot be reached" + ) + + +def validate_outbound_url( + url: str, *, allowed_schemes: tuple = ("http", "https") +) -> None: + """Reject a URL whose scheme is not allowed or whose host is not public.""" + scheme = urlparse(url).scheme + if scheme and scheme not in allowed_schemes: + raise OutboundURLNotAllowedError( + f"Disallowed URL scheme {scheme!r}. Allowed: {', '.join(allowed_schemes)}" + ) + validate_outbound_host(extract_host(url)) diff --git a/prowler/providers/openstack/exceptions/exceptions.py b/prowler/providers/openstack/exceptions/exceptions.py index f5b7dc9a7d..47abf8238f 100644 --- a/prowler/providers/openstack/exceptions/exceptions.py +++ b/prowler/providers/openstack/exceptions/exceptions.py @@ -54,6 +54,10 @@ class OpenStackBaseException(ProwlerException): "message": "Ambiguous region configuration in clouds.yaml", "remediation": "Use either 'region_name' or 'regions' in your cloud configuration, not both.", }, + (17012, "OpenStackAuthUrlNotAllowedError"): { + "message": "OpenStack auth_url points at a destination the connection test cannot reach", + "remediation": "Use an http or https auth_url that resolves to a public address, or allow the private network through PROWLER_ALLOWED_PRIVATE_NETWORKS.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -212,3 +216,15 @@ class OpenStackAmbiguousRegionError(OpenStackBaseException): original_exception=original_exception, message=message, ) + + +class OpenStackAuthUrlNotAllowedError(OpenStackBaseException): + """Exception for an auth_url rejected by the outbound URL guard""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + code=17012, + file=file, + original_exception=original_exception, + message=message, + ) diff --git a/prowler/providers/openstack/openstack_provider.py b/prowler/providers/openstack/openstack_provider.py index e11c4f7909..e9aeb18362 100644 --- a/prowler/providers/openstack/openstack_provider.py +++ b/prowler/providers/openstack/openstack_provider.py @@ -14,12 +14,14 @@ from prowler.config.config import ( load_and_validate_config_file, ) from prowler.lib.logger import logger +from prowler.lib.network.ssrf import OutboundURLNotAllowedError, validate_outbound_url from prowler.lib.utils.utils import print_boxes from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider from prowler.providers.openstack.exceptions.exceptions import ( OpenStackAmbiguousRegionError, OpenStackAuthenticationError, + OpenStackAuthUrlNotAllowedError, OpenStackCloudNotFoundError, OpenStackConfigFileNotFoundError, OpenStackCredentialsError, @@ -454,6 +456,15 @@ class OpenstackProvider(Provider): message=f"Failed to load clouds.yaml configuration: {error}", ) + @staticmethod + def _validate_auth_url(auth_url: str) -> None: + """Reject an auth_url the worker must not reach; the detail stays in the log.""" + try: + validate_outbound_url(auth_url, allowed_schemes=("http", "https")) + except OutboundURLNotAllowedError as error: + logger.warning(f"OpenStack auth_url rejected: {error}") + raise OpenStackAuthUrlNotAllowedError() + @staticmethod def _create_connection( session: OpenStackSession, @@ -617,6 +628,8 @@ class OpenstackProvider(Provider): project_domain_name=project_domain_name, ) + OpenstackProvider._validate_auth_url(session.auth_url) + # Validate provider_id matches project_id from config if provider_id and session.project_id != provider_id: raise OpenStackInvalidProviderIdError( @@ -636,6 +649,7 @@ class OpenstackProvider(Provider): except ( OpenStackCredentialsError, OpenStackAuthenticationError, + OpenStackAuthUrlNotAllowedError, OpenStackSessionError, OpenStackConfigFileNotFoundError, OpenStackCloudNotFoundError, diff --git a/tests/lib/network/__init__.py b/tests/lib/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/network/ssrf_test.py b/tests/lib/network/ssrf_test.py new file mode 100644 index 0000000000..4b8005d0e3 --- /dev/null +++ b/tests/lib/network/ssrf_test.py @@ -0,0 +1,173 @@ +import ipaddress +import socket +from unittest import mock + +import pytest + +from prowler.lib.network.ssrf import ( + ALLOWED_PRIVATE_NETWORKS_ENV, + OutboundURLNotAllowedError, + allowed_private_networks, + extract_host, + validate_outbound_host, + validate_outbound_url, +) + + +def _resolves_to(*addresses): + return mock.patch.object( + socket, + "getaddrinfo", + return_value=[(2, 1, 6, "", (address, 0)) for address in addresses], + ) + + +class TestValidateOutboundHost: + @pytest.mark.parametrize( + "address", + [ + "127.0.0.1", + "10.0.0.5", + "192.168.1.1", + "172.16.0.1", + "169.254.169.254", + "0.0.0.0", + "224.0.0.1", + "198.18.0.1", + "203.0.113.1", + "::1", + "fe80::1", + "fc00::1", + "ff02::1", + "2001:db8::1", + ], + ) + def test_rejects_non_public_literals(self, address): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host(address) + + @pytest.mark.parametrize( + "address", ["100.64.0.1", "100.100.100.200", "100.127.255.254"] + ) + def test_rejects_shared_address_space(self, address): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host(address) + + @pytest.mark.parametrize( + "address", ["8.8.8.8", "1.1.1.1", "140.82.121.4", "2606:4700:4700::1111"] + ) + def test_allows_public_literals(self, address): + validate_outbound_host(address) + + @pytest.mark.parametrize( + "address", + ["::ffff:169.254.169.254", "2002:a00:5::", "64:ff9b::a00:5"], + ) + def test_rejects_ipv6_embedding_a_non_public_ipv4(self, address): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host(address) + + def test_rejects_a_hostname_resolving_to_a_private_address(self): + with _resolves_to("10.0.0.5"): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host("registry.internal") + + def test_rejects_a_hostname_with_one_non_public_answer(self): + with _resolves_to("8.8.8.8", "127.0.0.1"): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host("split-horizon.example.com") + + def test_allows_a_hostname_resolving_to_a_public_address(self): + with _resolves_to("140.82.121.4"): + validate_outbound_host("github.com") + + def test_rejects_a_hostname_that_does_not_resolve(self): + with mock.patch.object( + socket, "getaddrinfo", side_effect=socket.gaierror("no such host") + ): + with pytest.raises(OutboundURLNotAllowedError, match="Could not resolve"): + validate_outbound_host("nowhere.invalid") + + +class TestAllowedPrivateNetworks: + def test_is_empty_when_unset(self, monkeypatch): + monkeypatch.delenv(ALLOWED_PRIVATE_NETWORKS_ENV, raising=False) + assert allowed_private_networks() == () + + @pytest.mark.parametrize("raw", ["", " ", ",", " , "]) + def test_is_empty_for_blank_values(self, monkeypatch, raw): + monkeypatch.setenv(ALLOWED_PRIVATE_NETWORKS_ENV, raw) + assert allowed_private_networks() == () + + def test_parses_addresses_and_cidrs(self, monkeypatch): + monkeypatch.setenv( + ALLOWED_PRIVATE_NETWORKS_ENV, "10.20.0.0/16, 192.168.65.254 ,fc00::/7" + ) + assert allowed_private_networks() == ( + ipaddress.ip_network("10.20.0.0/16"), + ipaddress.ip_network("192.168.65.254/32"), + ipaddress.ip_network("fc00::/7"), + ) + + def test_rejects_a_malformed_entry(self, monkeypatch): + monkeypatch.setenv(ALLOWED_PRIVATE_NETWORKS_ENV, "10.20.0.0/16,not-a-network") + with pytest.raises(OutboundURLNotAllowedError, match="Malformed entry"): + allowed_private_networks() + + def test_allows_an_address_inside_an_allowlisted_range(self, monkeypatch): + monkeypatch.setenv(ALLOWED_PRIVATE_NETWORKS_ENV, "10.20.0.0/16") + validate_outbound_host("10.20.1.5") + + def test_still_rejects_an_address_outside_the_allowlisted_range(self, monkeypatch): + monkeypatch.setenv(ALLOWED_PRIVATE_NETWORKS_ENV, "10.20.0.0/16") + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host("169.254.169.254") + + def test_does_not_match_an_allowlist_entry_of_another_family(self, monkeypatch): + monkeypatch.setenv(ALLOWED_PRIVATE_NETWORKS_ENV, "fc00::/7") + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_host("10.20.1.5") + + +class TestExtractHost: + @pytest.mark.parametrize( + "url, expected", + [ + ("https://github.com/org/repo", "github.com"), + ( + "https://user:token@github.com/org/repo", # trufflehog:ignore + "github.com", + ), + ("https://github.com:8443/org/repo", "github.com"), + ("git@github.com:org/repo.git", "github.com"), + ("github.com:org/repo.git", "github.com"), + ("ssh://git@github.com/org/repo.git", "github.com"), + ], + ) + def test_reads_the_host(self, url, expected): + assert extract_host(url) == expected + + def test_rejects_a_url_without_a_host(self): + with pytest.raises(OutboundURLNotAllowedError, match="Could not read a host"): + extract_host("not a url") + + +class TestValidateOutboundURL: + def test_rejects_a_disallowed_scheme(self): + with pytest.raises(OutboundURLNotAllowedError, match="Disallowed URL scheme"): + validate_outbound_url("file:///etc/passwd") + + def test_allows_an_explicitly_permitted_scheme(self): + with _resolves_to("140.82.121.4"): + validate_outbound_url( + "ssh://git@github.com/org/repo.git", + allowed_schemes=("http", "https", "ssh", "git"), + ) + + def test_rejects_a_non_public_host_on_an_allowed_scheme(self): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_url("http://169.254.169.254/latest/meta-data") + + def test_rejects_shared_address_space_on_an_allowed_scheme(self): + with pytest.raises(OutboundURLNotAllowedError): + validate_outbound_url("http://100.100.100.200/latest/meta-data") diff --git a/tests/providers/openstack/openstack_provider_test.py b/tests/providers/openstack/openstack_provider_test.py index b36fcd97a5..3b3a348890 100644 --- a/tests/providers/openstack/openstack_provider_test.py +++ b/tests/providers/openstack/openstack_provider_test.py @@ -16,6 +16,7 @@ from prowler.providers.common.models import Connection from prowler.providers.openstack.exceptions.exceptions import ( OpenStackAmbiguousRegionError, OpenStackAuthenticationError, + OpenStackAuthUrlNotAllowedError, OpenStackCloudNotFoundError, OpenStackConfigFileNotFoundError, OpenStackCredentialsError, @@ -26,6 +27,23 @@ from prowler.providers.openstack.exceptions.exceptions import ( from prowler.providers.openstack.models import OpenStackIdentityInfo, OpenStackSession from prowler.providers.openstack.openstack_provider import OpenstackProvider +PUBLIC_IP = "8.8.8.8" + + +def _fake_getaddrinfo(host_to_ip: dict): + def _getaddrinfo(host, *_args, **_kwargs): + return [(None, None, None, None, (host_to_ip.get(host, PUBLIC_IP), 0))] + + return _getaddrinfo + + +@pytest.fixture(autouse=True) +def _dns_resolves_public(monkeypatch): + """Keep the outbound URL guard offline: every hostname resolves to a public IP.""" + monkeypatch.setattr( + "prowler.lib.network.ssrf.socket.getaddrinfo", _fake_getaddrinfo({}) + ) + class TestOpenstackProvider: """Test suite for OpenStack Provider initialization.""" @@ -1620,3 +1638,126 @@ clouds: assert result.is_connected is False assert isinstance(result.error, OpenStackInvalidProviderIdError) + + +class TestOpenstackProviderAuthUrlGuard: + """Test suite for the outbound URL guard applied to auth_url in test_connection.""" + + CLOUDS_YAML = """ +clouds: + test-cloud: + auth: + auth_url: {auth_url} + username: test-user + password: test-password + project_id: test-project-id + region_name: RegionOne +""" + + @staticmethod + def _test_connection(**kwargs) -> tuple[Connection, MagicMock]: + with patch( + "prowler.providers.openstack.openstack_provider.connect" + ) as mock_connect: + mock_connect.return_value = MagicMock() + result = OpenstackProvider.test_connection( + username="test-user", + password="test-password", + project_id="test-project-id", + region_name="RegionOne", + raise_on_exception=False, + **kwargs, + ) + return result, mock_connect + + @pytest.mark.parametrize( + "auth_url", + [ + "https://127.0.0.1:5000/v3", + "https://[::1]:5000/v3", + "https://10.0.0.5:5000/v3", + "https://172.16.0.5:5000/v3", + "https://192.168.1.5:5000/v3", + "http://169.254.169.254/latest/meta-data", + "ftp://keystone.example.com:5000/v3", + ], + ) + def test_test_connection_rejects_non_public_auth_url(self, auth_url): + result, mock_connect = self._test_connection(auth_url=auth_url) + + assert result.is_connected is False + assert isinstance(result.error, OpenStackAuthUrlNotAllowedError) + mock_connect.assert_not_called() + + def test_test_connection_rejects_hostname_resolving_to_private_ip( + self, monkeypatch + ): + monkeypatch.setattr( + "prowler.lib.network.ssrf.socket.getaddrinfo", + _fake_getaddrinfo({"keystone.example.com": "10.0.0.5"}), + ) + + result, mock_connect = self._test_connection( + auth_url="https://keystone.example.com:5000/v3" + ) + + assert result.is_connected is False + assert isinstance(result.error, OpenStackAuthUrlNotAllowedError) + mock_connect.assert_not_called() + + def test_test_connection_rejection_does_not_echo_the_target(self): + result, _ = self._test_connection( + auth_url="https://placeholder-user:placeholder-token@10.0.0.5:5000/v3" # trufflehog:ignore + ) + + assert isinstance(result.error, OpenStackAuthUrlNotAllowedError) + assert result.error.original_exception is None + for fragment in ("10.0.0.5", "placeholder-token", "non-public", "resolves"): + assert fragment not in str(result.error) + + def test_test_connection_rejection_raises_when_requested(self): + with patch("prowler.providers.openstack.openstack_provider.connect"): + with pytest.raises(OpenStackAuthUrlNotAllowedError): + OpenstackProvider.test_connection( + auth_url="https://127.0.0.1:5000/v3", + username="test-user", + password="test-password", + project_id="test-project-id", + region_name="RegionOne", + raise_on_exception=True, + ) + + def test_test_connection_allows_public_auth_url(self): + result, mock_connect = self._test_connection( + auth_url="https://openstack.example.com:5000/v3" + ) + + assert result.is_connected is True + assert result.error is None + mock_connect.assert_called_once() + + def test_test_connection_rejects_private_auth_url_from_clouds_yaml_content( + self, + ): + result, mock_connect = self._test_connection( + clouds_yaml_content=self.CLOUDS_YAML.format( + auth_url="https://127.0.0.1:5000/v3" + ), + clouds_yaml_cloud="test-cloud", + ) + + assert result.is_connected is False + assert isinstance(result.error, OpenStackAuthUrlNotAllowedError) + mock_connect.assert_not_called() + + def test_test_connection_allows_public_auth_url_from_clouds_yaml_content(self): + result, mock_connect = self._test_connection( + clouds_yaml_content=self.CLOUDS_YAML.format( + auth_url="https://openstack.example.com:5000/v3" + ), + clouds_yaml_cloud="test-cloud", + ) + + assert result.is_connected is True + assert result.error is None + mock_connect.assert_called_once()