mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
fix(iac): block SSRF in repository connection test
This commit is contained in:
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
|
||||
Whitespace-only changes.
@@ -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))
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user