55"""
66
77import re
8+ import socket
89from urllib .parse import urlparse , urlunparse
910from typing import Optional , List , Set
1011from 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:
134182def 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 )
0 commit comments