@@ -88,6 +88,48 @@ def test_allowlist_blocks_non_matching_ipv4_with_mixed_version_networks(self):
8888 with pytest .raises (ValueError , match = "private/internal" ):
8989 validate_url ("http://example.com" , allowed_ips = allowed )
9090
91+ def test_allowed_hosts_bypasses_private_ip_check (self ):
92+ """Hostnames in WEBHOOK_ALLOWED_HOSTS skip IP-based blocking — used for
93+ trusted internal services (e.g. Silo) whose IPs are dynamic in
94+ containerised deployments."""
95+ with patch ("plane.utils.ip_address.socket.getaddrinfo" ) as mock_dns :
96+ mock_dns .return_value = [(None , None , None , None , ("172.18.0.5" , 0 ))]
97+ validate_url ("http://silo:3000/hook" , allowed_hosts = ["silo" ]) # Should not raise
98+
99+ def test_allowed_hosts_matches_case_insensitively (self ):
100+ with patch ("plane.utils.ip_address.socket.getaddrinfo" ) as mock_dns :
101+ mock_dns .return_value = [(None , None , None , None , ("10.0.0.1" , 0 ))]
102+ validate_url (
103+ "http://Silo.Namespace.Svc.Cluster.Local/x" ,
104+ allowed_hosts = ["silo.namespace.svc.cluster.local" ],
105+ ) # Should not raise
106+
107+ def test_allowed_hosts_skips_dns_lookup (self ):
108+ """When the hostname is explicitly trusted we shouldn't even resolve it —
109+ protects against operators who allowlist a name that isn't resolvable
110+ from the API container."""
111+ with patch ("plane.utils.ip_address.socket.getaddrinfo" ) as mock_dns :
112+ validate_url ("http://silo/hook" , allowed_hosts = ["silo" ])
113+ mock_dns .assert_not_called ()
114+
115+ def test_allowed_hosts_requires_exact_match (self ):
116+ """Subdomains of an allowed host must NOT bypass — a hostile
117+ ``attacker.silo.internal`` should still be blocked when only
118+ ``silo.internal`` is allowed."""
119+ with patch ("plane.utils.ip_address.socket.getaddrinfo" ) as mock_dns :
120+ mock_dns .return_value = [(None , None , None , None , ("192.168.1.1" , 0 ))]
121+ with pytest .raises (ValueError , match = "private/internal" ):
122+ validate_url (
123+ "http://attacker.silo.internal/x" ,
124+ allowed_hosts = ["silo.internal" ],
125+ )
126+
127+ def test_allowed_hosts_empty_does_not_bypass (self ):
128+ with patch ("plane.utils.ip_address.socket.getaddrinfo" ) as mock_dns :
129+ mock_dns .return_value = [(None , None , None , None , ("10.0.0.1" , 0 ))]
130+ with pytest .raises (ValueError , match = "private/internal" ):
131+ validate_url ("http://silo/hook" , allowed_hosts = [])
132+
91133
92134@pytest .mark .unit
93135class TestSafeGet :
0 commit comments