mirror of
https://github.com/furyhawk/agent_delta.git
synced 2026-07-21 10:15:36 +00:00
195 lines
5.8 KiB
Python
195 lines
5.8 KiB
Python
"""Tests for SSRF protection in webhook URL validation.
|
|
|
|
Covers validate_webhook_url() and _is_ip_blocked() from app.core.sanitize.
|
|
"""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from app.core.sanitize import (
|
|
SSRFBlockedError,
|
|
_is_ip_blocked,
|
|
validate_webhook_url,
|
|
)
|
|
|
|
# _is_ip_blocked
|
|
|
|
|
|
class TestIsIpBlocked:
|
|
"""Tests for the _is_ip_blocked helper."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"ip",
|
|
[
|
|
"127.0.0.1",
|
|
"10.0.0.1",
|
|
"172.16.0.1",
|
|
"192.168.1.1",
|
|
"169.254.169.254",
|
|
"0.0.0.0",
|
|
"::1",
|
|
"fe80::1",
|
|
"fc00::1",
|
|
"100.100.100.200", # CGNAT / Alibaba Cloud metadata
|
|
"100.64.0.1", # CGNAT range start (RFC 6598)
|
|
],
|
|
)
|
|
def test_blocked_ips(self, ip: str):
|
|
assert _is_ip_blocked(ip) is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"ip",
|
|
[
|
|
"8.8.8.8",
|
|
"1.1.1.1",
|
|
"93.184.216.34", # example.com
|
|
"2606:4700:4700::1111", # Cloudflare public DNS IPv6
|
|
],
|
|
)
|
|
def test_allowed_ips(self, ip: str):
|
|
assert _is_ip_blocked(ip) is False
|
|
|
|
def test_unparseable_ip_is_blocked(self):
|
|
"""If we can't parse it, we block it (fail-closed)."""
|
|
assert _is_ip_blocked("not-an-ip") is True
|
|
|
|
|
|
# validate_webhook_url — scheme validation
|
|
|
|
|
|
class TestSchemeValidation:
|
|
"""Blocked schemes must raise SSRFBlockedError."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"file:///etc/passwd",
|
|
"ftp://mirror.example.com/pub",
|
|
"gopher://evil.com/",
|
|
"data:text/html,<h1>hi</h1>",
|
|
],
|
|
)
|
|
def test_blocked_schemes(self, url: str):
|
|
with pytest.raises(SSRFBlockedError):
|
|
validate_webhook_url(url)
|
|
|
|
def test_empty_scheme_is_rejected(self):
|
|
with pytest.raises((SSRFBlockedError, ValueError)):
|
|
validate_webhook_url("://example.com/hook")
|
|
|
|
|
|
# validate_webhook_url — IP-literal URLs
|
|
|
|
|
|
class TestDirectIpUrls:
|
|
"""URLs with IP-address hostnames (no DNS involved)."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://127.0.0.1/hook",
|
|
"https://169.254.169.254/latest/meta-data/",
|
|
"http://10.0.0.1:8080/callback",
|
|
"http://192.168.1.1/hook",
|
|
"http://[::1]/hook",
|
|
"http://0.0.0.0/hook",
|
|
"http://100.100.100.200/latest/meta-data/", # CGNAT / Alibaba metadata
|
|
],
|
|
)
|
|
def test_private_ip_blocked(self, url: str):
|
|
with pytest.raises(SSRFBlockedError):
|
|
validate_webhook_url(url)
|
|
|
|
def test_public_ip_allowed(self):
|
|
"""A public IP should pass validation (DNS resolution is skipped)."""
|
|
url = "https://93.184.216.34/webhook"
|
|
assert validate_webhook_url(url) == url
|
|
|
|
|
|
# validate_webhook_url — DNS resolution to private IP
|
|
|
|
|
|
class TestDnsResolution:
|
|
"""DNS resolving to a private IP must be blocked."""
|
|
|
|
def _mock_getaddrinfo_private(self, *args, **kwargs):
|
|
"""Return a private IP for any hostname."""
|
|
return [
|
|
(2, 1, 6, "", ("127.0.0.1", 443)),
|
|
]
|
|
|
|
def _mock_getaddrinfo_public(self, *args, **kwargs):
|
|
"""Return a public IP for any hostname."""
|
|
return [
|
|
(2, 1, 6, "", ("93.184.216.34", 443)),
|
|
]
|
|
|
|
def test_dns_resolves_to_private_ip(self):
|
|
with (
|
|
patch("app.core.sanitize.socket.getaddrinfo", self._mock_getaddrinfo_private),
|
|
pytest.raises(SSRFBlockedError),
|
|
):
|
|
validate_webhook_url("https://evil.attacker.com/hook")
|
|
|
|
def test_dns_resolves_to_public_ip(self):
|
|
with patch("app.core.sanitize.socket.getaddrinfo", self._mock_getaddrinfo_public):
|
|
result = validate_webhook_url("https://example.com/webhook")
|
|
assert result == "https://example.com/webhook"
|
|
|
|
|
|
# validate_webhook_url — edge cases
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Edge cases: empty URL, missing hostname, credentials in URL."""
|
|
|
|
def test_empty_url(self):
|
|
with pytest.raises((SSRFBlockedError, ValueError)):
|
|
validate_webhook_url("")
|
|
|
|
def test_no_hostname(self):
|
|
with pytest.raises(ValueError):
|
|
validate_webhook_url("https:///path")
|
|
|
|
def test_url_with_credentials_rejected(self):
|
|
"""URLs with userinfo (user:pass@) should be rejected."""
|
|
with pytest.raises(SSRFBlockedError):
|
|
validate_webhook_url("http://user:pass@internal.example.com/hook")
|
|
|
|
def test_url_with_username_only_rejected(self):
|
|
with pytest.raises(SSRFBlockedError):
|
|
validate_webhook_url("http://admin@169.254.169.254/")
|
|
|
|
def test_allowed_https_url(self):
|
|
"""A normal public URL should pass (mock DNS to avoid network calls)."""
|
|
with patch(
|
|
"app.core.sanitize.socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("93.184.216.34", 443))],
|
|
):
|
|
result = validate_webhook_url("https://example.com/webhook")
|
|
assert result == "https://example.com/webhook"
|
|
|
|
def test_allowed_http_url(self):
|
|
"""An http:// URL to a public IP should also pass."""
|
|
with patch(
|
|
"app.core.sanitize.socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("93.184.216.34", 80))],
|
|
):
|
|
result = validate_webhook_url("http://example.com/webhook")
|
|
assert result == "http://example.com/webhook"
|
|
|
|
|
|
# SSRFBlockedError is a subclass of ValueError
|
|
|
|
|
|
class TestSSRFBlockedError:
|
|
"""The dedicated exception type preserves backward compatibility."""
|
|
|
|
def test_is_value_error_subclass(self):
|
|
assert issubclass(SSRFBlockedError, ValueError)
|
|
|
|
def test_catchable_as_value_error(self):
|
|
with pytest.raises(ValueError):
|
|
raise SSRFBlockedError("blocked")
|