feat: add CIDR/netmask support for trusted proxy IPs
TRUSTED_PROXY_IPS now accepts CIDR notation (e.g., 192.168.1.0/24) in addition to exact IP addresses. Supports both IPv4 and IPv6. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -227,6 +227,109 @@ class TestTrustedProxyIPs:
|
||||
settings = Settings()
|
||||
assert settings.trusted_proxy_ips == []
|
||||
|
||||
def test_cidr_notation_parsed(self):
|
||||
"""CIDR notation should be parsed into networks."""
|
||||
import ipaddress
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TRUSTED_PROXY_IPS": "192.168.1.0/24", "CSRF_SECRET": "test-secret"},
|
||||
clear=True,
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
networks = settings.trusted_proxy_networks
|
||||
assert len(networks) == 1
|
||||
assert isinstance(networks[0], ipaddress.IPv4Network)
|
||||
assert str(networks[0]) == "192.168.1.0/24"
|
||||
|
||||
def test_plain_ip_parsed_as_single_host_network(self):
|
||||
"""Plain IP should be parsed as /32 network."""
|
||||
import ipaddress
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TRUSTED_PROXY_IPS": "10.0.0.1", "CSRF_SECRET": "test-secret"},
|
||||
clear=True,
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
networks = settings.trusted_proxy_networks
|
||||
assert len(networks) == 1
|
||||
assert isinstance(networks[0], ipaddress.IPv4Network)
|
||||
# Plain IP becomes /32 network
|
||||
assert networks[0].num_addresses == 1
|
||||
|
||||
def test_mixed_ips_and_cidrs(self):
|
||||
"""Mix of plain IPs and CIDR notation should all be parsed."""
|
||||
import ipaddress
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"TRUSTED_PROXY_IPS": "10.0.0.1,192.168.0.0/16,172.16.0.0/12",
|
||||
"CSRF_SECRET": "test-secret",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
networks = settings.trusted_proxy_networks
|
||||
assert len(networks) == 3
|
||||
# All should be network objects
|
||||
assert all(
|
||||
isinstance(n, (ipaddress.IPv4Network, ipaddress.IPv6Network)) for n in networks
|
||||
)
|
||||
|
||||
def test_ipv6_cidr_supported(self):
|
||||
"""IPv6 CIDR notation should be supported."""
|
||||
import ipaddress
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TRUSTED_PROXY_IPS": "::1,fd00::/8", "CSRF_SECRET": "test-secret"},
|
||||
clear=True,
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
networks = settings.trusted_proxy_networks
|
||||
assert len(networks) == 2
|
||||
assert any(isinstance(n, ipaddress.IPv6Network) for n in networks)
|
||||
|
||||
def test_invalid_cidr_becomes_literal(self):
|
||||
"""Invalid CIDR notation should become a literal for string matching."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TRUSTED_PROXY_IPS": "192.168.1.0/33", "CSRF_SECRET": "test-secret"},
|
||||
clear=True,
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
# Invalid CIDR should not appear in networks
|
||||
assert len(settings.trusted_proxy_networks) == 0
|
||||
# But should appear in literals
|
||||
assert "192.168.1.0/33" in settings.trusted_proxy_literals
|
||||
|
||||
def test_invalid_ip_becomes_literal(self):
|
||||
"""Invalid IP address should become a literal for string matching."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TRUSTED_PROXY_IPS": "not-an-ip", "CSRF_SECRET": "test-secret"},
|
||||
clear=True,
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
# Invalid IP should not appear in networks
|
||||
assert len(settings.trusted_proxy_networks) == 0
|
||||
# But should appear in literals
|
||||
assert "not-an-ip" in settings.trusted_proxy_literals
|
||||
|
||||
|
||||
class TestCsrfSecretRequired:
|
||||
"""Test that CSRF_SECRET is required."""
|
||||
|
||||
@@ -300,3 +300,89 @@ class TestLoggingAfter:
|
||||
assert before_ms <= parsed["ts"] <= after_ms
|
||||
# Should be a reasonable timestamp (year 2020+)
|
||||
assert parsed["ts"] > 1577836800000 # 2020-01-01
|
||||
|
||||
|
||||
class TestIsTrustedProxyCIDR:
|
||||
"""Tests for CIDR support in is_trusted_proxy."""
|
||||
|
||||
def test_ip_within_cidr_is_trusted(self):
|
||||
"""IP within CIDR range should be trusted."""
|
||||
from animaltrack.web.middleware import is_trusted_proxy
|
||||
|
||||
req = MagicMock()
|
||||
req.client = MagicMock(host="192.168.1.50")
|
||||
|
||||
settings = make_test_settings(trusted_proxy_ips="192.168.1.0/24")
|
||||
|
||||
assert is_trusted_proxy(req, settings) is True
|
||||
|
||||
def test_ip_outside_cidr_not_trusted(self):
|
||||
"""IP outside CIDR range should not be trusted."""
|
||||
from animaltrack.web.middleware import is_trusted_proxy
|
||||
|
||||
req = MagicMock()
|
||||
req.client = MagicMock(host="192.168.2.50")
|
||||
|
||||
settings = make_test_settings(trusted_proxy_ips="192.168.1.0/24")
|
||||
|
||||
assert is_trusted_proxy(req, settings) is False
|
||||
|
||||
def test_exact_ip_still_works(self):
|
||||
"""Exact IP matching should still work."""
|
||||
from animaltrack.web.middleware import is_trusted_proxy
|
||||
|
||||
req = MagicMock()
|
||||
req.client = MagicMock(host="10.0.0.1")
|
||||
|
||||
settings = make_test_settings(trusted_proxy_ips="10.0.0.1")
|
||||
|
||||
assert is_trusted_proxy(req, settings) is True
|
||||
|
||||
def test_mixed_exact_and_cidr(self):
|
||||
"""Mix of exact IPs and CIDR should work."""
|
||||
from animaltrack.web.middleware import is_trusted_proxy
|
||||
|
||||
settings = make_test_settings(trusted_proxy_ips="10.0.0.1,192.168.0.0/16")
|
||||
|
||||
# Exact IP match
|
||||
req1 = MagicMock()
|
||||
req1.client = MagicMock(host="10.0.0.1")
|
||||
assert is_trusted_proxy(req1, settings) is True
|
||||
|
||||
# CIDR match
|
||||
req2 = MagicMock()
|
||||
req2.client = MagicMock(host="192.168.100.200")
|
||||
assert is_trusted_proxy(req2, settings) is True
|
||||
|
||||
# No match
|
||||
req3 = MagicMock()
|
||||
req3.client = MagicMock(host="172.16.0.1")
|
||||
assert is_trusted_proxy(req3, settings) is False
|
||||
|
||||
def test_ipv6_cidr_matching(self):
|
||||
"""IPv6 CIDR matching should work."""
|
||||
from animaltrack.web.middleware import is_trusted_proxy
|
||||
|
||||
settings = make_test_settings(trusted_proxy_ips="fd00::/8")
|
||||
|
||||
req = MagicMock()
|
||||
req.client = MagicMock(host="fd12:3456:789a::1")
|
||||
assert is_trusted_proxy(req, settings) is True
|
||||
|
||||
req2 = MagicMock()
|
||||
req2.client = MagicMock(host="fe80::1")
|
||||
assert is_trusted_proxy(req2, settings) is False
|
||||
|
||||
def test_localhost_cidr(self):
|
||||
"""Localhost CIDR should work."""
|
||||
from animaltrack.web.middleware import is_trusted_proxy
|
||||
|
||||
settings = make_test_settings(trusted_proxy_ips="127.0.0.0/8")
|
||||
|
||||
req = MagicMock()
|
||||
req.client = MagicMock(host="127.0.0.1")
|
||||
assert is_trusted_proxy(req, settings) is True
|
||||
|
||||
req2 = MagicMock()
|
||||
req2.client = MagicMock(host="127.255.255.255")
|
||||
assert is_trusted_proxy(req2, settings) is True
|
||||
|
||||
Reference in New Issue
Block a user