Root cause
SSRF & network boundaries introduced
fix: critical security vulnerabilities in tools
How AI contributed: Direct introductionPublished Jun 18, 2026 in
mervinpraison/praisonaiAll 19 findings in this repository
Claude-bot code added a URL check to spider_tools.py that blocked loopback and private addresses written as literal strings but never resolved the hostname. A DNS name pointing at an internal address passed every pattern check and the scraper issued the request, allowing requests to internal services and cloud metadata addresses. The fix adds a socket.getaddrinfo resolution loop to _host_is_blocked and treats any resolved loopback, private, link-local or reserved address as blocked.
The check compares the host text only, so _validate_url and its _host_is_blocked successor never see the address that a hostname actually resolves to. Names and alternative literal encodings reach the scraper unfiltered, and the repair resolves each name with socket.getaddrinfo and rejects private, loopback, link-local and reserved results.
Root cause
fix: critical security vulnerabilities in tools
Fix
refactor: harden input validation and query parameterization
AI-assisted fix: Cursor · MervinPraison
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"}@@ -13,6 +13,7 @@ from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING from importlib import util import json+import re if TYPE_CHECKING: import duckdb@@ -29,6 +30,25 @@ def __init__(self, database: str = ':memory:'): """ self.database = database self._conn = None+ + def _validate_identifier(self, identifier: str) -> str:+ """Validate and quote a SQL identifier to prevent injection.+ + Args:+ identifier: Table or column name to validate+ + Returns:+ Quoted identifier safe for SQL+ + Raises:+ ValueError: If identifier contains invalid characters+ """+ # Only allow alphanumeric characters, underscores, and dots+ if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$', identifier):+ raise ValueError(f"Invalid identifier: {identifier}")+ + # Quote the identifier to handle reserved words+ return f'"{identifier}"' def _get_duckdb(self) -> Optional['duckdb']: """Get duckdb module, installing if needed"""@@ -125,39 +145,50 @@ def load_csv( if conn is None: return False - # Check if table exists- exists = conn.execute(f"""+ # Check if table exists using parameterized query+ exists = conn.execute(""" SELECT name FROM sqlite_master - WHERE type='table' AND name='{table_name}'- """).fetchone() is not None+ WHERE type='table' AND name=?+ """, [table_name]).fetchone() is not None if exists: if if_exists == 'fail': raise ValueError(f"Table {table_name} already exists") elif if_exists == 'replace':- conn.execute(f"DROP TABLE IF EXISTS {table_name}")+ # Validate and quote table name to prevent injection+ safe_table = self._validate_identifier(table_name)+ conn.execute(f"DROP TABLE IF EXISTS {safe_table}") elif if_exists != 'append': raise ValueError("if_exists must be 'fail', 'replace', or 'append'") # Create table if needed if not exists or if_exists == 'replace':+ safe_table = self._validate_identifier(table_name) if schema:- # Create table with schema- columns = ', '.join(f"{k} {v}" for k, v in schema.items())- conn.execute(f"CREATE TABLE {table_name} ({columns})")+ # Create table with schema - validate column names+ column_defs = []+ for col_name, col_type in schema.items():+ safe_col = self._validate_identifier(col_name)+ # Validate column type to prevent injection+ if not re.match(r'^[A-Z][A-Z0-9_]*(\([0-9,]+\))?$', col_type.upper()):+ raise ValueError(f"Invalid column type: {col_type}")+ column_defs.append(f"{safe_col} {col_type}")+ columns = ', '.join(column_defs)+ conn.execute(f"CREATE TABLE {safe_table} ({columns})") else:- # Infer schema from CSV+ # Infer schema from CSV - use parameterized query for filepath conn.execute(f"""- CREATE TABLE {table_name} AS - SELECT * FROM read_csv_auto('{filepath}')+ CREATE TABLE {safe_table} AS + SELECT * FROM read_csv_auto(?) WHERE 1=0- """)+ """, [filepath]) - # Load data+ # Load data - use validated table name and parameterized filepath@@ -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,@@ -58,9 +58,11 @@ def _ip_blocked(ip: ipaddress._BaseAddress) -> bool: pass try:- return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host)))- except OSError:- pass+ for info in socket.getaddrinfo(host, None):+ if _ip_blocked(ipaddress.ip_address(info[4][0])):+ return True+ except socket.gaierror:+ return True return False AI-assisted change f11f310f97cf01a7475a802fd47b901d80bc14baa91ae4e1009c1d093c3d5b5d · Fix 5bde1d35eb458a17dd6de861edf8b0ec712835d284b39d64838c9e4b9bf9ab65