fix(iac): block SSRF in repository connection test

This commit is contained in:
pedrooot committed 2026-10-06 14:34:42 +02:00
1 parent d38e04dd3e
commit 08de675133
5 files changed
+225 -2

No files matched your search

@@ -0,0 +1 @@
IaC connection test rejects repository URLs resolving to loopback, private or link-local hosts and stops echoing raw errors to the caller
View File
Whitespace-only changes.
+138
View File
@@ -0,0 +1,138 @@
"""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<host>[^:/]+):(?!//)")
_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
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))
+22 -2
View File
@@ -17,6 +17,10 @@ from prowler.config.config import (
)
from prowler.lib.check.models import CheckReportIAC
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.lib.utils.vulnerability_references import (
build_finding_reference_url,
@@ -652,6 +656,18 @@ class IacProvider(Provider):
is_connected=False, error="Repository URL is required"
)
try:
validate_outbound_url(
scan_repository_url,
allowed_schemes=("http", "https", "ssh", "git"),
)
except OutboundURLNotAllowedError as error:
logger.warning(f"Rejected IaC repository URL: {error}")
return Connection(
is_connected=False,
error="Repository URL is not an allowed destination.",
)
# Try to clone the repository to test the connection
with tempfile.TemporaryDirectory():
try:
@@ -676,7 +692,10 @@ class IacProvider(Provider):
return Connection(is_connected=True)
except Exception as e:
# The raw error may carry the token-bearing URL or the
# remote's response, so it is logged and never returned
error_msg = str(e)
logger.error(f"IaC repository connection test failed: {error_msg}")
if "authentication" in error_msg.lower() or "401" in error_msg:
return Connection(
is_connected=False,
@@ -690,13 +709,14 @@ class IacProvider(Provider):
else:
return Connection(
is_connected=False,
error=f"Failed to connect to repository: {error_msg}",
error="Failed to connect to repository.",
)
except Exception as error:
if raise_on_exception:
raise
logger.error(f"Unexpected error testing IaC repository connection: {error}")
return Connection(
is_connected=False,
error=f"Unexpected error testing connection: {str(error)}",
error="Unexpected error testing connection.",
)
+64
View File
@@ -878,3 +878,67 @@ class TestIacProvider:
# Pass a non-existent directory
branch_name = provider._detect_branch_name("/non/existent/path")
assert branch_name == "main"
def test_test_connection_rejects_loopback_url(self):
with patch(
"prowler.providers.iac.iac_provider.porcelain.ls_remote"
) as mock_ls_remote:
connection = IacProvider.test_connection(
scan_repository_url="https://127.0.0.1/user/repo.git"
)
assert connection.is_connected is False
assert connection.error == "Repository URL is not an allowed destination."
mock_ls_remote.assert_not_called()
def test_test_connection_rejects_private_range_url(self):
with patch(
"prowler.providers.iac.iac_provider.porcelain.ls_remote"
) as mock_ls_remote:
connection = IacProvider.test_connection(
scan_repository_url="https://10.0.0.1/user/repo.git"
)
assert connection.is_connected is False
assert connection.error == "Repository URL is not an allowed destination."
mock_ls_remote.assert_not_called()
def test_test_connection_allows_public_url(self):
with (
patch(
"prowler.lib.network.ssrf.socket.getaddrinfo",
return_value=[(None, None, None, None, ("140.82.121.4", 0))],
) as mock_getaddrinfo,
patch(
"prowler.providers.iac.iac_provider.porcelain.ls_remote"
) as mock_ls_remote,
):
connection = IacProvider.test_connection(
scan_repository_url="https://github.com/user/repo.git"
)
assert connection.is_connected is True
mock_getaddrinfo.assert_called_once_with("github.com", None)
mock_ls_remote.assert_called_once_with("https://github.com/user/repo.git")
def test_test_connection_does_not_echo_raw_error(self):
with (
patch(
"prowler.lib.network.ssrf.socket.getaddrinfo",
return_value=[(None, None, None, None, ("140.82.121.4", 0))],
),
patch(
"prowler.providers.iac.iac_provider.porcelain.ls_remote",
side_effect=Exception(
"https://x-access-token:SENTINEL_TOKEN@github.com/user/repo.git refused"
),
),
):
connection = IacProvider.test_connection(
scan_repository_url="https://github.com/user/repo.git",
oauth_app_token="SENTINEL_TOKEN",
)
assert connection.is_connected is False
assert connection.error == "Failed to connect to repository."
assert "SENTINEL_TOKEN" not in connection.error