7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
635 lines
22 KiB
Python
635 lines
22 KiB
Python
"""
|
|
Extended tests for the SSRF validator module.
|
|
|
|
Provides comprehensive coverage of is_ip_blocked, get_safe_url, validate_url,
|
|
and module constants (ALWAYS_BLOCKED_METADATA_IPS, ALLOWED_SCHEMES).
|
|
"""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.security.ssrf_validator import (
|
|
ALWAYS_BLOCKED_METADATA_IPS,
|
|
ALLOWED_SCHEMES,
|
|
get_safe_url,
|
|
is_ip_blocked,
|
|
validate_url,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAlwaysBlockedMetadataIPsConstant:
|
|
"""Verify the ALWAYS_BLOCKED_METADATA_IPS frozenset."""
|
|
|
|
def test_contains_aws_imds(self):
|
|
"""AWS IMDS / Azure / OCI / DigitalOcean shared endpoint."""
|
|
assert "169.254.169.254" in ALWAYS_BLOCKED_METADATA_IPS
|
|
|
|
def test_contains_aws_ecs_v3_and_v4(self):
|
|
assert "169.254.170.2" in ALWAYS_BLOCKED_METADATA_IPS
|
|
assert "169.254.170.23" in ALWAYS_BLOCKED_METADATA_IPS
|
|
|
|
def test_contains_alibaba_and_tencent(self):
|
|
assert "100.100.100.200" in ALWAYS_BLOCKED_METADATA_IPS
|
|
assert "169.254.0.23" in ALWAYS_BLOCKED_METADATA_IPS
|
|
|
|
|
|
class TestAllowedSchemesConstant:
|
|
"""Verify the ALLOWED_SCHEMES constant."""
|
|
|
|
def test_http_in_allowed_schemes(self):
|
|
"""http must be an allowed scheme."""
|
|
assert "http" in ALLOWED_SCHEMES
|
|
|
|
def test_https_in_allowed_schemes(self):
|
|
"""https must be an allowed scheme."""
|
|
assert "https" in ALLOWED_SCHEMES
|
|
|
|
def test_ftp_not_in_allowed_schemes(self):
|
|
"""ftp must NOT be an allowed scheme."""
|
|
assert "ftp" not in ALLOWED_SCHEMES
|
|
|
|
def test_file_not_in_allowed_schemes(self):
|
|
"""file must NOT be an allowed scheme."""
|
|
assert "file" not in ALLOWED_SCHEMES
|
|
|
|
def test_allowed_schemes_is_a_set(self):
|
|
"""ALLOWED_SCHEMES should be a set for O(1) lookups."""
|
|
assert isinstance(ALLOWED_SCHEMES, set)
|
|
|
|
def test_allowed_schemes_contains_only_expected_values(self):
|
|
"""ALLOWED_SCHEMES should contain exactly http and https."""
|
|
assert ALLOWED_SCHEMES == {"http", "https"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ip_blocked -- public / safe IPs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsIpBlockedPublicIPs:
|
|
"""Public IPs must never be blocked regardless of flags."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"ip",
|
|
[
|
|
"8.8.8.8",
|
|
"8.8.4.4",
|
|
"1.1.1.1",
|
|
"1.0.0.1",
|
|
"208.67.222.222",
|
|
"142.250.185.206",
|
|
"93.184.216.34",
|
|
"198.51.100.1",
|
|
"203.0.113.5",
|
|
],
|
|
)
|
|
def test_public_ipv4_not_blocked(self, ip):
|
|
"""Well-known public IPv4 addresses are not blocked."""
|
|
assert is_ip_blocked(ip) is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"ip",
|
|
[
|
|
"8.8.8.8",
|
|
"1.1.1.1",
|
|
"93.184.216.34",
|
|
],
|
|
)
|
|
def test_public_ipv4_not_blocked_with_allow_localhost(self, ip):
|
|
"""Public IPs remain unblocked when allow_localhost=True."""
|
|
assert is_ip_blocked(ip, allow_localhost=True) is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"ip",
|
|
[
|
|
"8.8.8.8",
|
|
"1.1.1.1",
|
|
],
|
|
)
|
|
def test_public_ipv4_not_blocked_with_allow_private_ips(self, ip):
|
|
"""Public IPs remain unblocked when allow_private_ips=True."""
|
|
assert is_ip_blocked(ip, allow_private_ips=True) is False
|
|
|
|
def test_public_ipv6_not_blocked(self):
|
|
"""Public IPv6 addresses are not blocked."""
|
|
# Google public DNS IPv6
|
|
assert is_ip_blocked("2001:4860:4860::8888") is False
|
|
|
|
def test_public_ipv6_not_blocked_with_flags(self):
|
|
"""Public IPv6 remains unblocked regardless of flags."""
|
|
assert (
|
|
is_ip_blocked("2001:4860:4860::8888", allow_localhost=True) is False
|
|
)
|
|
assert (
|
|
is_ip_blocked("2001:4860:4860::8888", allow_private_ips=True)
|
|
is False
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ip_blocked -- localhost / loopback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsIpBlockedLoopback:
|
|
"""Loopback addresses blocked by default, allowed via flags."""
|
|
|
|
def test_127_0_0_1_blocked_by_default(self):
|
|
"""Standard IPv4 loopback is blocked."""
|
|
assert is_ip_blocked("127.0.0.1") is True
|
|
|
|
def test_127_0_0_2_blocked_by_default(self):
|
|
"""Alternate IPv4 loopback address in 127/8 is blocked."""
|
|
assert is_ip_blocked("127.0.0.2") is True
|
|
|
|
def test_127_255_255_255_blocked_by_default(self):
|
|
"""End of IPv4 loopback range is blocked."""
|
|
assert is_ip_blocked("127.255.255.255") is True
|
|
|
|
def test_ipv6_loopback_blocked_by_default(self):
|
|
"""IPv6 loopback ::1 is blocked."""
|
|
assert is_ip_blocked("::1") is True
|
|
|
|
def test_127_0_0_1_allowed_with_allow_localhost(self):
|
|
"""allow_localhost=True unblocks 127.0.0.1."""
|
|
assert is_ip_blocked("127.0.0.1", allow_localhost=True) is False
|
|
|
|
def test_127_range_allowed_with_allow_localhost(self):
|
|
"""allow_localhost=True unblocks the whole 127/8 range."""
|
|
assert is_ip_blocked("127.0.0.2", allow_localhost=True) is False
|
|
assert is_ip_blocked("127.255.255.255", allow_localhost=True) is False
|
|
|
|
def test_ipv6_loopback_allowed_with_allow_localhost(self):
|
|
"""allow_localhost=True unblocks ::1."""
|
|
assert is_ip_blocked("::1", allow_localhost=True) is False
|
|
|
|
def test_127_0_0_1_allowed_with_allow_private_ips(self):
|
|
"""allow_private_ips=True also unblocks loopback."""
|
|
assert is_ip_blocked("127.0.0.1", allow_private_ips=True) is False
|
|
|
|
def test_ipv6_loopback_allowed_with_allow_private_ips(self):
|
|
"""allow_private_ips=True also unblocks IPv6 loopback."""
|
|
assert is_ip_blocked("::1", allow_private_ips=True) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ip_blocked -- RFC1918 private ranges
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsIpBlockedRFC1918:
|
|
"""RFC1918 private addresses blocked by default, allowed with allow_private_ips."""
|
|
|
|
# -- 10.0.0.0/8 --
|
|
|
|
def test_10_0_0_1_blocked(self):
|
|
assert is_ip_blocked("10.0.0.1") is True
|
|
|
|
def test_10_255_255_255_blocked(self):
|
|
assert is_ip_blocked("10.255.255.255") is True
|
|
|
|
def test_10_x_allowed_with_allow_private_ips(self):
|
|
assert is_ip_blocked("10.0.0.1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("10.255.255.255", allow_private_ips=True) is False
|
|
|
|
def test_10_x_still_blocked_with_allow_localhost(self):
|
|
"""allow_localhost does NOT unblock RFC1918."""
|
|
assert is_ip_blocked("10.0.0.1", allow_localhost=True) is True
|
|
|
|
# -- 172.16.0.0/12 --
|
|
|
|
def test_172_16_0_1_blocked(self):
|
|
assert is_ip_blocked("172.16.0.1") is True
|
|
|
|
def test_172_31_255_255_blocked(self):
|
|
assert is_ip_blocked("172.31.255.255") is True
|
|
|
|
def test_172_15_255_255_not_blocked(self):
|
|
"""172.15.x.x is outside 172.16.0.0/12 and therefore public."""
|
|
assert is_ip_blocked("172.15.255.255") is False
|
|
|
|
def test_172_32_0_0_not_blocked(self):
|
|
"""172.32.x.x is outside 172.16.0.0/12 and therefore public."""
|
|
assert is_ip_blocked("172.32.0.0") is False
|
|
|
|
def test_172_16_x_allowed_with_allow_private_ips(self):
|
|
assert is_ip_blocked("172.16.0.1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("172.31.255.255", allow_private_ips=True) is False
|
|
|
|
def test_172_16_x_still_blocked_with_allow_localhost(self):
|
|
assert is_ip_blocked("172.16.0.1", allow_localhost=True) is True
|
|
|
|
# -- 192.168.0.0/16 --
|
|
|
|
def test_192_168_1_1_blocked(self):
|
|
assert is_ip_blocked("192.168.1.1") is True
|
|
|
|
def test_192_168_0_1_blocked(self):
|
|
assert is_ip_blocked("192.168.0.1") is True
|
|
|
|
def test_192_168_255_255_blocked(self):
|
|
assert is_ip_blocked("192.168.255.255") is True
|
|
|
|
def test_192_168_x_allowed_with_allow_private_ips(self):
|
|
assert is_ip_blocked("192.168.1.1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("192.168.255.255", allow_private_ips=True) is False
|
|
|
|
def test_192_168_x_still_blocked_with_allow_localhost(self):
|
|
assert is_ip_blocked("192.168.1.1", allow_localhost=True) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ip_blocked -- AWS metadata endpoint (ALWAYS blocked)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsIpBlockedAWSMetadata:
|
|
"""169.254.169.254 must ALWAYS be blocked -- the #1 SSRF target."""
|
|
|
|
def test_aws_metadata_blocked_default(self):
|
|
assert is_ip_blocked("169.254.169.254") is True
|
|
|
|
def test_aws_metadata_blocked_with_allow_localhost(self):
|
|
assert is_ip_blocked("169.254.169.254", allow_localhost=True) is True
|
|
|
|
def test_aws_metadata_blocked_with_allow_private_ips(self):
|
|
"""Critical: even when all private IPs are allowed, AWS metadata stays blocked."""
|
|
assert is_ip_blocked("169.254.169.254", allow_private_ips=True) is True
|
|
|
|
def test_aws_metadata_blocked_with_both_flags(self):
|
|
"""Both flags together must not unblock AWS metadata."""
|
|
assert (
|
|
is_ip_blocked(
|
|
"169.254.169.254", allow_localhost=True, allow_private_ips=True
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ip_blocked -- CGNAT (100.64.0.0/10)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsIpBlockedCGNAT:
|
|
"""CGNAT range used by Podman/rootless containers."""
|
|
|
|
def test_cgnat_start_blocked(self):
|
|
assert is_ip_blocked("100.64.0.1") is True
|
|
|
|
def test_cgnat_middle_blocked(self):
|
|
assert is_ip_blocked("100.100.100.100") is True
|
|
|
|
def test_cgnat_end_blocked(self):
|
|
assert is_ip_blocked("100.127.255.255") is True
|
|
|
|
def test_cgnat_allowed_with_allow_private_ips(self):
|
|
assert is_ip_blocked("100.64.0.1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("100.100.100.100", allow_private_ips=True) is False
|
|
assert is_ip_blocked("100.127.255.255", allow_private_ips=True) is False
|
|
|
|
def test_cgnat_blocked_with_allow_localhost_only(self):
|
|
"""allow_localhost does NOT unblock CGNAT."""
|
|
assert is_ip_blocked("100.64.0.1", allow_localhost=True) is True
|
|
|
|
def test_just_outside_cgnat_not_blocked(self):
|
|
"""100.128.0.0 is outside the 100.64.0.0/10 range and public."""
|
|
assert is_ip_blocked("100.128.0.0") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ip_blocked -- link-local (169.254.0.0/16)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsIpBlockedLinkLocal:
|
|
"""Link-local addresses (169.254.x.x) blocked by default."""
|
|
|
|
def test_link_local_blocked(self):
|
|
assert is_ip_blocked("169.254.1.1") is True
|
|
assert is_ip_blocked("169.254.100.100") is True
|
|
|
|
def test_link_local_beginning_blocked(self):
|
|
assert is_ip_blocked("169.254.0.1") is True
|
|
|
|
def test_link_local_end_blocked(self):
|
|
assert is_ip_blocked("169.254.255.255") is True
|
|
|
|
def test_non_aws_link_local_allowed_with_allow_private_ips(self):
|
|
"""Non-AWS link-local addresses allowed with allow_private_ips."""
|
|
assert is_ip_blocked("169.254.1.1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("169.254.100.100", allow_private_ips=True) is False
|
|
assert is_ip_blocked("169.254.0.1", allow_private_ips=True) is False
|
|
|
|
def test_aws_metadata_still_blocked_within_link_local(self):
|
|
"""AWS metadata is a special case within link-local; always blocked."""
|
|
assert is_ip_blocked("169.254.169.254", allow_private_ips=True) is True
|
|
|
|
def test_link_local_blocked_with_allow_localhost_only(self):
|
|
"""allow_localhost does NOT unblock link-local."""
|
|
assert is_ip_blocked("169.254.1.1", allow_localhost=True) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ip_blocked -- IPv6 private ranges
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsIpBlockedIPv6Private:
|
|
"""IPv6 ULA (fc00::/7) and link-local (fe80::/10) blocked by default."""
|
|
|
|
# -- Unique Local Addresses (fc00::/7) --
|
|
|
|
def test_fc00_blocked(self):
|
|
assert is_ip_blocked("fc00::1") is True
|
|
|
|
def test_fd00_blocked(self):
|
|
assert is_ip_blocked("fd00::1") is True
|
|
|
|
def test_fd_range_blocked(self):
|
|
"""fd12:3456:789a::1 is in fc00::/7."""
|
|
assert is_ip_blocked("fd12:3456:789a::1") is True
|
|
|
|
def test_fc00_allowed_with_allow_private_ips(self):
|
|
assert is_ip_blocked("fc00::1", allow_private_ips=True) is False
|
|
|
|
def test_fd00_allowed_with_allow_private_ips(self):
|
|
assert is_ip_blocked("fd00::1", allow_private_ips=True) is False
|
|
|
|
def test_fc00_blocked_with_allow_localhost_only(self):
|
|
assert is_ip_blocked("fc00::1", allow_localhost=True) is True
|
|
|
|
# -- Link-Local (fe80::/10) --
|
|
|
|
def test_fe80_blocked(self):
|
|
assert is_ip_blocked("fe80::1") is True
|
|
|
|
def test_fe80_with_interface_id_blocked(self):
|
|
assert is_ip_blocked("fe80::1234:5678") is True
|
|
|
|
def test_fe80_allowed_with_allow_private_ips(self):
|
|
assert is_ip_blocked("fe80::1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("fe80::1234:5678", allow_private_ips=True) is False
|
|
|
|
def test_fe80_blocked_with_allow_localhost_only(self):
|
|
assert is_ip_blocked("fe80::1", allow_localhost=True) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ip_blocked -- "this" network (0.0.0.0/8)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsIpBlockedZeroNetwork:
|
|
"""The 0.0.0.0/8 'this' network is blocked."""
|
|
|
|
def test_0_0_0_0_blocked(self):
|
|
assert is_ip_blocked("0.0.0.0") is True
|
|
|
|
def test_0_x_x_x_blocked(self):
|
|
assert is_ip_blocked("0.1.2.3") is True
|
|
|
|
def test_0_255_255_255_blocked(self):
|
|
assert is_ip_blocked("0.255.255.255") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ip_blocked -- invalid / edge-case inputs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsIpBlockedInvalidInputs:
|
|
"""Invalid or non-IP inputs must return False (not blocked)."""
|
|
|
|
def test_invalid_ip_string_returns_false(self):
|
|
assert is_ip_blocked("not-an-ip") is False
|
|
|
|
def test_empty_string_returns_false(self):
|
|
assert is_ip_blocked("") is False
|
|
|
|
def test_hostname_returns_false(self):
|
|
assert is_ip_blocked("example.com") is False
|
|
|
|
def test_url_returns_false(self):
|
|
"""A full URL is not a valid IP string."""
|
|
assert is_ip_blocked("http://127.0.0.1") is False
|
|
|
|
def test_ip_with_port_returns_false(self):
|
|
"""IP:port is not a valid IP address."""
|
|
assert is_ip_blocked("127.0.0.1:8080") is False
|
|
|
|
def test_ip_with_trailing_space_returns_false(self):
|
|
"""Whitespace around an IP makes it invalid."""
|
|
assert is_ip_blocked(" 127.0.0.1 ") is False
|
|
|
|
def test_ip_with_cidr_returns_false(self):
|
|
"""CIDR notation is not a single IP address."""
|
|
assert is_ip_blocked("10.0.0.0/8") is False
|
|
|
|
def test_negative_numbers_returns_false(self):
|
|
assert is_ip_blocked("-1.0.0.0") is False
|
|
|
|
def test_oversized_octet_returns_false(self):
|
|
assert is_ip_blocked("256.256.256.256") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ip_blocked -- combined flags
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsIpBlockedCombinedFlags:
|
|
"""Test behaviour when multiple flags are provided together."""
|
|
|
|
def test_both_flags_allows_loopback(self):
|
|
assert (
|
|
is_ip_blocked(
|
|
"127.0.0.1", allow_localhost=True, allow_private_ips=True
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_both_flags_allows_private(self):
|
|
assert (
|
|
is_ip_blocked(
|
|
"192.168.1.1", allow_localhost=True, allow_private_ips=True
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_both_flags_still_blocks_aws_metadata(self):
|
|
assert (
|
|
is_ip_blocked(
|
|
"169.254.169.254", allow_localhost=True, allow_private_ips=True
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_both_flags_does_not_block_public(self):
|
|
assert (
|
|
is_ip_blocked(
|
|
"8.8.8.8", allow_localhost=True, allow_private_ips=True
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_ip_blocked -- boundary IPs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsIpBlockedBoundaries:
|
|
"""Test IPs at the exact boundaries of blocked ranges."""
|
|
|
|
def test_first_ip_of_10_range(self):
|
|
assert is_ip_blocked("10.0.0.0") is True
|
|
|
|
def test_last_ip_of_10_range(self):
|
|
assert is_ip_blocked("10.255.255.255") is True
|
|
|
|
def test_just_before_10_range_not_blocked(self):
|
|
"""9.255.255.255 is just before 10.0.0.0/8."""
|
|
assert is_ip_blocked("9.255.255.255") is False
|
|
|
|
def test_just_after_10_range_not_blocked(self):
|
|
"""11.0.0.0 is just after 10.0.0.0/8."""
|
|
assert is_ip_blocked("11.0.0.0") is False
|
|
|
|
def test_first_ip_of_172_16_range(self):
|
|
assert is_ip_blocked("172.16.0.0") is True
|
|
|
|
def test_last_ip_of_172_16_range(self):
|
|
assert is_ip_blocked("172.31.255.255") is True
|
|
|
|
def test_ip_172_15_255_255_not_blocked(self):
|
|
"""Just below the 172.16.0.0/12 range."""
|
|
assert is_ip_blocked("172.15.255.255") is False
|
|
|
|
def test_ip_172_32_0_0_not_blocked(self):
|
|
"""Just above the 172.16.0.0/12 range."""
|
|
assert is_ip_blocked("172.32.0.0") is False
|
|
|
|
def test_first_ip_of_192_168_range(self):
|
|
assert is_ip_blocked("192.168.0.0") is True
|
|
|
|
def test_last_ip_of_192_168_range(self):
|
|
assert is_ip_blocked("192.168.255.255") is True
|
|
|
|
def test_ip_192_167_255_255_not_blocked(self):
|
|
assert is_ip_blocked("192.167.255.255") is False
|
|
|
|
def test_ip_192_169_0_0_not_blocked(self):
|
|
assert is_ip_blocked("192.169.0.0") is False
|
|
|
|
def test_first_ip_of_cgnat(self):
|
|
assert is_ip_blocked("100.64.0.0") is True
|
|
|
|
def test_last_ip_of_cgnat(self):
|
|
assert is_ip_blocked("100.127.255.255") is True
|
|
|
|
def test_ip_100_63_255_255_not_blocked(self):
|
|
"""Just below CGNAT range."""
|
|
assert is_ip_blocked("100.63.255.255") is False
|
|
|
|
def test_ip_100_128_0_0_not_blocked(self):
|
|
"""Just above CGNAT range."""
|
|
assert is_ip_blocked("100.128.0.0") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_safe_url -- None / empty handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetSafeUrlNoneAndEmpty:
|
|
"""get_safe_url returns default for None or empty strings."""
|
|
|
|
def test_none_returns_default_none(self):
|
|
assert get_safe_url(None) is None
|
|
|
|
def test_none_returns_custom_default(self):
|
|
assert (
|
|
get_safe_url(None, default="https://fallback.com")
|
|
== "https://fallback.com"
|
|
)
|
|
|
|
def test_empty_string_returns_default_none(self):
|
|
assert get_safe_url("") is None
|
|
|
|
def test_empty_string_returns_custom_default(self):
|
|
assert (
|
|
get_safe_url("", default="https://fallback.com")
|
|
== "https://fallback.com"
|
|
)
|
|
|
|
|
|
class TestGetSafeUrlPassthrough:
|
|
"""get_safe_url passes through valid URLs and blocks unsafe ones."""
|
|
|
|
def test_non_empty_url_returned(self):
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
|
|
):
|
|
result = get_safe_url("https://example.com")
|
|
assert result == "https://example.com"
|
|
|
|
def test_http_url_returned(self):
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
|
|
):
|
|
result = get_safe_url("http://example.com")
|
|
assert result == "http://example.com"
|
|
|
|
def test_url_with_path_returned(self):
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
|
|
):
|
|
result = get_safe_url("https://example.com/path?q=1")
|
|
assert result == "https://example.com/path?q=1"
|
|
|
|
def test_localhost_url_returns_default(self):
|
|
"""validate_url blocks localhost, so get_safe_url returns default."""
|
|
result = get_safe_url("http://127.0.0.1:8080", default="safe")
|
|
assert result == "safe"
|
|
|
|
def test_default_not_used_for_valid_url(self):
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
|
|
):
|
|
result = get_safe_url(
|
|
"https://example.com", default="https://fallback.com"
|
|
)
|
|
assert result == "https://example.com"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_url -- real validation (no test bypass)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateUrlRealValidation:
|
|
"""Confirm validate_url performs real SSRF validation."""
|
|
|
|
def test_blocks_private_ip_url(self):
|
|
"""Private IP URL is blocked."""
|
|
assert validate_url("http://192.168.1.1") is False
|
|
|
|
def test_blocks_localhost_url(self):
|
|
"""Localhost URL is blocked."""
|
|
assert validate_url("http://127.0.0.1") is False
|
|
|
|
def test_blocks_aws_metadata_url(self):
|
|
"""AWS metadata URL is blocked."""
|
|
assert validate_url("http://169.254.169.254/latest/meta-data") is False
|