Skip to content

Commit b5e3ba9

Browse files
committed
Fix: URLSanitizer allows SSRF via hostnames without DNS/IP revalidation
1 parent 8cc3e31 commit b5e3ba9

2 files changed

Lines changed: 106 additions & 7 deletions

File tree

security/url_sanitizer.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import re
8+
import socket
89
from urllib.parse import urlparse, urlunparse
910
from typing import Optional, List, Set
1011
from ipaddress import ip_address, ip_network
@@ -29,18 +30,32 @@ class URLSanitizer:
2930
"file", "gopher", "data", "javascript", "vbscript",
3031
"about", "view-source", "ws", "wss"
3132
}
32-
33+
34+
BLOCKED_PORTS: Set[int] = {
35+
7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42,
36+
43, 53, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110,
37+
111, 113, 115, 117, 119, 123, 135, 139, 143, 179, 389,
38+
443, 445, 465, 512, 513, 514, 515, 526, 530, 531, 532,
39+
540, 548, 554, 556, 563, 587, 601, 636, 993, 995,
40+
1433, 1434, 1521, 2049, 2375, 2376, 3128, 3306, 3389,
41+
4333, 5432, 5900, 5901, 6000, 6001, 6379, 6666, 6667,
42+
6668, 6669, 7001, 7002, 8000, 8080, 8081, 8443, 9000,
43+
9001, 9090, 9200, 9300, 11211, 27017, 27018, 27019,
44+
}
45+
3346
def __init__(
3447
self,
3548
allowed_schemes: Optional[List[str]] = None,
3649
allow_localhost: bool = False,
3750
allow_private: bool = False,
38-
max_length: int = 2048
51+
max_length: int = 2048,
52+
allowed_ports: Optional[List[int]] = None,
3953
):
4054
self.allowed_schemes = allowed_schemes or ['http', 'https']
4155
self.allow_localhost = allow_localhost
4256
self.allow_private = allow_private
4357
self.max_length = max_length
58+
self.allowed_ports = allowed_ports
4459
self._blocked_networks = [ip_network(net) for net in self.BLOCKED_NETWORKS]
4560

4661
def validate_url(self, url: str) -> str:
@@ -79,7 +94,8 @@ def validate_url(self, url: str) -> str:
7994
host = parsed.hostname
8095
if not host:
8196
raise InvalidURLError("URL must have a valid hostname")
82-
97+
98+
self._validate_port(parsed.port)
8399
self._validate_ip_address(host)
84100
self._validate_characters(url)
85101

@@ -95,17 +111,49 @@ def validate_url(self, url: str) -> str:
95111
return sanitized
96112

97113
def _validate_ip_address(self, host: str) -> None:
98-
"""Validate IP address against blocked networks."""
114+
"""Validate IP address against blocked networks, resolving hostnames via DNS."""
99115
try:
100116
ip = ip_address(host)
101-
except ValueError:
117+
self._check_blocked_ip(ip, host)
102118
return
103-
119+
except ValueError:
120+
pass
121+
122+
try:
123+
addrinfo = socket.getaddrinfo(host, None)
124+
except socket.gaierror:
125+
raise InvalidURLError(f"Failed to resolve hostname: {host}")
126+
127+
seen = set()
128+
for _, _, _, _, sockaddr in addrinfo:
129+
ip = ip_address(sockaddr[0])
130+
if ip in seen:
131+
continue
132+
seen.add(ip)
133+
self._check_blocked_ip(ip, host)
134+
135+
def _check_blocked_ip(self, ip, host):
136+
"""Check a single IP against blocked networks."""
104137
for network in self._blocked_networks:
105138
if ip in network:
106139
if not (self.allow_localhost and ip.is_loopback):
107140
if not (self.allow_private and ip.is_private):
108141
raise InvalidURLError(f"Blocked IP address: {host}")
142+
143+
def _validate_port(self, port: Optional[int]) -> None:
144+
"""Validate port number against allowed/blocked lists."""
145+
if port is None:
146+
return
147+
if port < 1 or port > 65535:
148+
raise InvalidURLError(f"Invalid port number: {port}")
149+
if self.allowed_ports is not None:
150+
if port not in self.allowed_ports:
151+
raise InvalidURLError(
152+
f"Port {port} is not allowed. "
153+
f"Allowed ports: {', '.join(str(p) for p in self.allowed_ports)}"
154+
)
155+
elif port in self.BLOCKED_PORTS:
156+
raise InvalidURLError(f"Blocked port: {port}")
109157

110158
def _validate_characters(self, url: str) -> None:
111159
"""Check for unsafe or malicious characters."""
@@ -134,8 +182,13 @@ def is_safe_url(self, url: str) -> bool:
134182
def validate_url(
135183
url: str,
136184
allowed_schemes: Optional[List[str]] = None,
185+
allowed_ports: Optional[List[int]] = None,
137186
**kwargs
138187
) -> str:
139188
"""Convenience function to validate a URL."""
140-
sanitizer = URLSanitizer(allowed_schemes=allowed_schemes, **kwargs)
189+
sanitizer = URLSanitizer(
190+
allowed_schemes=allowed_schemes,
191+
allowed_ports=allowed_ports,
192+
**kwargs
193+
)
141194
return sanitizer.validate_url(url)

tests/test_security.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,52 @@ def test_allow_private_ips(self):
7878
result = sanitizer.validate_url(url)
7979
assert result.startswith("http://10.0.0.1")
8080

81+
def test_hostname_resolves_to_private_ip(self):
82+
"""Test that hostnames resolving to private IPs are blocked."""
83+
sanitizer = URLSanitizer(allow_localhost=False, allow_private=False)
84+
with pytest.raises(InvalidURLError):
85+
sanitizer.validate_url("http://localhost")
86+
87+
def test_hostname_resolves_to_private_ip_with_allow_localhost(self):
88+
sanitizer = URLSanitizer(allow_localhost=True)
89+
result = sanitizer.validate_url("http://localhost")
90+
assert result.startswith("http://localhost")
91+
92+
def test_hostname_resolves_to_private_ip_with_allow_private(self):
93+
sanitizer = URLSanitizer(allow_private=True)
94+
result = sanitizer.validate_url("http://localhost")
95+
assert result.startswith("http://localhost")
96+
97+
def test_unresolvable_hostname_raises_error(self):
98+
sanitizer = URLSanitizer()
99+
with pytest.raises(InvalidURLError):
100+
sanitizer.validate_url("http://this-domain-does-not-exist-12345.com")
101+
102+
def test_blocked_port(self):
103+
sanitizer = URLSanitizer()
104+
with pytest.raises(InvalidURLError):
105+
sanitizer.validate_url("http://example.com:22")
106+
107+
def test_blocked_port_with_hostname_private_ip(self):
108+
sanitizer = URLSanitizer(allow_localhost=True)
109+
with pytest.raises(InvalidURLError):
110+
sanitizer.validate_url("http://localhost:22")
111+
112+
def test_allowed_ports_allowlist(self):
113+
sanitizer = URLSanitizer(allowed_ports=[80, 443])
114+
result = sanitizer.validate_url("http://example.com:80")
115+
assert result.startswith("http://example.com:80")
116+
117+
def test_allowed_ports_rejects_non_allowed(self):
118+
sanitizer = URLSanitizer(allowed_ports=[80, 443])
119+
with pytest.raises(InvalidURLError):
120+
sanitizer.validate_url("http://example.com:8080")
121+
122+
def test_valid_port_not_blocked(self):
123+
sanitizer = URLSanitizer()
124+
result = sanitizer.validate_url("http://example.com:8888")
125+
assert result.startswith("http://example.com:8888")
126+
81127
def test_blocked_schemes(self):
82128
# These should be blocked because they're in BLOCKED_SCHEMES
83129
sanitizer = URLSanitizer()

0 commit comments

Comments
 (0)