Root cause
SSRF & network boundaries
fix: critical security vulnerabilities in tools
How AI contributed: Flawed AI-written codePublished May 6, 2026 in
mervinpraison/praisonaiAll 19 findings in this repository
The AI-written _validate_url in spider_tools.py parsed candidate URLs with urllib.parse.urlparse and blocked private, loopback and reserved hostnames. A backslash placed before the @ symbol makes urlparse report the host as 1.1.1.1 while requests dispatches the connection to 127.0.0.1, so a smuggled URL passed validation and requests.get reached an internal address. The fix rejects any URL containing a backslash or ASCII control character before parsing.
Validation trusted one library's reading of the authority: _validate_url took the hostname from urllib.parse.urlparse, while the request was ultimately issued by requests, and the two disagree when a backslash appears in the authority. Rejecting backslashes and ASCII control characters before parsing closes the divergence.
Root cause
fix: critical security vulnerabilities in tools
Fix
fix(security): reject SSRF-smuggling URL chars in spider_tools._validate_url (#1578)
Fix by Mervin Praison · no AI marker found
Case facts
@@ -27,6 +27,57 @@ def __init__(self): """Initialize SpiderTools and check for required packages.""" self._session = None + def _validate_url(self, url: str) -> bool:+ """+ Validate URL to prevent SSRF attacks.+ + Args:+ url: URL to validate+ + Returns:+ bool: True if URL is safe, False otherwise+ """+ try:+ parsed = urlparse(url)+ + # Only allow http/https protocols+ if parsed.scheme not in ['http', 'https']:+ return False+ + # Reject URLs with no hostname+ if not parsed.hostname:+ return False+ + # Reject local/internal addresses+ hostname = parsed.hostname.lower()+ + # Block localhost and loopback+ if hostname in ['localhost', '127.0.0.1', '0.0.0.0', '::1']:+ return False+ + # Block private IP ranges+ import ipaddress+ try:+ ip = ipaddress.ip_address(hostname)+ if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local:+ return False+ except ValueError:+ # Not an IP address, continue with domain validation+ pass+ + # Block common internal domains+ if any(hostname.endswith(domain) for domain in ['.local', '.internal', '.localdomain']):+ return False+ + # Block metadata service endpoints+ if hostname in ['169.254.169.254', 'metadata.google.internal']:+ return False+ + return True+ + except Exception:+ return False+ def _get_session(self): """Get or create requests session with common headers.""" if util.find_spec('requests') is None:@@ -70,6 +121,10 @@ def scrape_page( Dict: Scraped content or error dict """ try:+ # Validate URL to prevent SSRF+ if not self._validate_url(url):+ return {"error": f"Invalid or potentially dangerous URL: {url}"}+ session = self._get_session() if session is None: return {"error": "requests package not available"}@@ -40,7 +40,6 @@ def execute_command( command: str, cwd: Optional[str] = None, timeout: int = 30,- shell: bool = False, env: Optional[Dict[str, str]] = None, max_output_size: int = 10000 ) -> Dict[str, Union[str, int, bool]]:@@ -50,22 +49,20 @@ def execute_command( command: Command to execute cwd: Working directory timeout: Maximum execution time in seconds- shell: Whether to run command in shell env: Environment variables max_output_size: Maximum output size in bytes Returns: Dictionary with execution results """ try:- # Split command if not using shell- if not shell:- # Use shlex.split with appropriate posix flag- if platform.system() == 'Windows':- # Use shlex with posix=False for Windows to handle quotes properly- command = shlex.split(command, posix=False)- else:- command = shlex.split(command)+ # Always split command for safety (no shell execution)+ # Use shlex.split with appropriate posix flag+ if platform.system() == 'Windows':+ # Use shlex with posix=False for Windows to handle quotes properly+ command = shlex.split(command, posix=False)+ else:+ command = shlex.split(command) # Set up process environment process_env = os.environ.copy()@@ -79,7 +76,7 @@ def execute_command( stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd,- shell=shell,+ shell=False, # Always use shell=False for security env=process_env, text=True )@@ -46,20 +46,100 @@ def execute_code( timeout: int = 30, max_output_size: int = 10000 ) -> Dict[str, Any]:- """Execute Python code safely."""+ """Execute Python code safely with restricted builtins.""" try:- # Set up execution environment+ # Create safe builtins - restricted set of functions+ safe_builtins = {+ # Basic functions+ 'print': print,+ 'len': len,+ 'range': range,+ 'enumerate': enumerate,+ 'zip': zip,+ 'map': map,+ 'filter': filter,+ 'sum': sum,+ 'min': min,+ 'max': max,+ 'abs': abs,+ 'round': round,+ 'sorted': sorted,+ 'reversed': reversed,+ 'any': any,+ 'all': all,+ # Type constructors+ 'int': int,+ 'float': float,+ 'str': str,+ 'bool': bool,+ 'list': list,+ 'tuple': tuple,+ 'dict': dict,+ 'set': set,+ # Math functions+ 'pow': pow,+ 'divmod': divmod,+ # Exceptions+ 'Exception': Exception,+ 'ValueError': ValueError,+ 'TypeError': TypeError,+ 'KeyError': KeyError,+ 'IndexError': IndexError,+ 'RuntimeError': RuntimeError,+ # Other safe functions+ 'isinstance': isinstance,+ 'type': type,+ 'hasattr': hasattr,+ 'getattr': getattr,+ 'setattr': setattr,+ 'dir': dir,+ 'help': help,+ # Disable dangerous functions+ '__import__': None,+ 'eval': None,+ 'exec': None,+ 'compile': None,+ 'open': None,+ 'input': None,+ 'globals': None,+ 'locals': None,+ 'vars': None,+ }+ + # Set up execution environment with safe builtins if globals_dict is None:- globals_dict = {'__builtins__': __builtins__}+ globals_dict = {'__builtins__': safe_builtins}+ else:+ # Override builtins in provided globals+ globals_dict['__builtins__'] = safe_builtins+ if locals_dict is None: locals_dict = {} + # Security check: validate code doesn't contain dangerous patterns+ dangerous_patterns = [+ '__import__', 'import ', 'from ', 'exec', 'eval', + 'compile', 'open(', 'file(', 'input(', 'raw_input',+ '__subclasses__', '__bases__', '__globals__', '__code__',+ '__class__', 'globals(', 'locals(', 'vars('+ ]+ + code_lower = code.lower()+ for pattern in dangerous_patterns:+ if pattern.lower() in code_lower:+ return {+ 'result': None,@@ -38,6 +38,17 @@ def _validate_url(self, url: str) -> bool: bool: True if URL is safe, False otherwise """ try:+ # Reject URLs containing characters that cause urlparse and the+ # underlying HTTP client (e.g. requests) to disagree on the+ # destination host. Backslashes and ASCII control characters in+ # the authority portion can be used to smuggle a private host+ # past hostname-based SSRF checks (e.g. ``http://127.0.0.1:6666\@1.1.1.1``+ # parses as host ``1.1.1.1`` but is dispatched to ``127.0.0.1``).+ if not isinstance(url, str):+ return False+ if "\\" in url or any(ord(c) < 0x20 or ord(c) == 0x7f for c in url):+ return False+ parsed = urlparse(url) # Only allow http/https protocols@@ -0,0 +1,44 @@+"""Regression tests for SSRF hardening in spider_tools._validate_url.++Covers GHSA-q9pw-vmhh-384g: a URL such as ``http://127.0.0.1:6666\\@1.1.1.1``+parses with hostname ``1.1.1.1`` via :func:`urllib.parse.urlparse` but is+dispatched to ``127.0.0.1`` by ``requests``. The validator must reject any+URL whose authority disagrees with the actual destination so that hostname+allow/deny checks cannot be smuggled past.+"""++from praisonaiagents.tools.spider_tools import SpiderTools+++def test_rejects_backslash_smuggle_in_authority():+ spider = SpiderTools()+ # Real-world bypass payload from the advisory.+ assert spider._validate_url("http://127.0.0.1:6666\\@1.1.1.1") is False+++def test_rejects_backslash_anywhere_in_url():+ spider = SpiderTools()+ assert spider._validate_url("http://example.com\\foo") is False+++def test_rejects_control_characters():+ spider = SpiderTools()+ assert spider._validate_url("http://example.com\x00.evil.com") is False+ assert spider._validate_url("http://example.com\r\n.evil.com") is False+++def test_allows_normal_public_url():+ spider = SpiderTools()+ assert spider._validate_url("https://example.com/path?q=1") is True+++def test_still_blocks_loopback():+ spider = SpiderTools()+ assert spider._validate_url("http://127.0.0.1:6666/") is False+ assert spider._validate_url("http://localhost/") is False+++def test_rejects_non_string_input():+ spider = SpiderTools()+ assert spider._validate_url(None) is False # type: ignore[arg-type]+ assert spider._validate_url(123) is False # type: ignore[arg-type]AI-assisted change 1c19ef34118c5c73491699b861186f7d9b72e3b45486a718b8ed22205968ea99 · Fix 4528fcba55e80b4162fb96b81f4829aa4a8bbbc852fb001c2b98cd7fa199e9d9