Original flaw
Earlier flawSpider URL fetching allowed requests to local or private destinations.
Sink: PraisonAI spider_tools hostname SSRF validator
Originally written by MervinPraison
How AI contributed: Incomplete remediationPublished May 29, 2026 in
mervinpraison/praisonaiAll 19 findings in this repository
Summary PraisonAI's spider_tools URL validation can be bypassed using alternate loopback host encodings. The affected component is: The tool contains a URL validation function intended to block local or unsafe targets before fetching attacker-controlled URLs. However, the validation only blocks a small set of exact host strings such as localhost and 127.0.0.1. It does not normalize hostnames, resolve DNS, parse numeric IPv4 variants, or validate the final resolved IP address before making the request. As a result, URLs such as the following bypass the protection and still reach loopback services: After the weak validation passes, scrape_page() calls requests.Session.get() on the attacker-controlled URL. This allows an attacker who can influence URLs passed to scrape_page, crawl, or extract_text to induce SSRF requests against loopback-only services. This is a server-side request forgery protection bypass. Details The affected code is in: The vulnerable flow is: The validation appears to block only exact local hostnames or exact IPv4 strings. For example, it blocks simple forms such as: However, equivalent loopback forms are not rejected before the request is made. Confirmed bypass examples: These values can resolve or be interpreted as loopback addresses by the HTTP client / underlying networking stack, while bypassing the string-based validation. The issue is not that spider_tools can fetch arbitrary URLs. The issue is that it attempts to provide SSRF protection, but the protection can be bypassed with alternate representations of loopback addresses. PoC The following PoC is non-destructive. It starts a local HTTP server on 127.0.0.1:8765, then sends several alternate loopback URL forms through the real spider_tools` validation/fetch path. The expected secure behavior is that all loopback variants should be rejected before any HTTP request is made. The actual vulnerable behavior is that the alternate loopback forms pass validation and reach the local server. Full PoC Confirmed local result The following bypasses were confirmed locally: This demonstrates that the validation allows alternate loopback representations and that the request reaches a local-only HTTP service. Expected secure behavior All loopback-equivalent addresses should be blocked before the HTTP request is made. Examples that should be rejected: Actual vulnerable behavior Several alternate loopback representations pass validation and are fetched by the tool. Impact An attacker who can influence URLs passed to PraisonAI's spider tools can cause the process to send HTTP requests to loopback-only services. Potential impact includes: · SSRF against localhost-only admin panels or development servers; · access to local HTTP services that are not intended to be reachable remotely; · retrieval of local service responses into the agent/tool output; · possible access to cloud metadata or private-network services if equivalent bypasses exist for those address ranges in a given deployment. The most direct confirmed impact is loopback SSRF through alternate hostname/IP encodings. This report does not claim arbitrary TCP access or remote code execution. The demonstrated behavior is HTTP(S) SSRF through the spider URL-fetching feature.
Cursor-assisted SSRF validation blocked only literal loopback names, so alternate IP encodings could reach internal services.
Case facts
Only the highlighted steps are this advisory. The first step is the earlier flaw the AI tried, and failed, to close.
Original flaw
Earlier flawSink: PraisonAI spider_tools hostname SSRF validator
Originally written by MervinPraison
This advisoryCVE-2026-47390 (GHSA-5C6W-WWFQ-7QQM)
AI tried to fix this
The AI-assisted change was a real security patch, but it left the same advisory reachable.
Missed: Did not normalize integer, hexadecimal, and other alternate loopback encodings.
Incomplete AI-assisted fix · Claude
Fixed again
This is the patch that actually stops the same attack path.
AI-assisted fix: Cursor · praison
@@ -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"}@@ -11,6 +11,8 @@ """ import logging+import ipaddress+import socket from typing import List, Dict, Union, Optional, Any from importlib import util import json@@ -20,6 +22,48 @@ import hashlib import time ++def _host_is_blocked(hostname: str) -> bool:+ """Return True when hostname resolves to loopback/private/internal targets."""+ if not hostname:+ return True+ host = hostname.lower().rstrip(".")+ if host in ("localhost", "0.0.0.0", "::1") or host.endswith(".localhost"):+ return True+ if host in ("169.254.169.254", "metadata.google.internal"):+ return True+ if any(host.endswith(suffix) for suffix in (".local", ".internal", ".localdomain")):+ return True++ def _ip_blocked(ip: ipaddress._BaseAddress) -> bool:+ return bool(+ ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local+ )++ if host.isdigit():+ try:+ return _ip_blocked(ipaddress.ip_address(int(host)))+ except (ValueError, OverflowError):+ return True++ if host.startswith("0x"):+ try:+ return _ip_blocked(ipaddress.ip_address(int(host, 16)))+ except (ValueError, OverflowError):+ return True++ try:+ return _ip_blocked(ipaddress.ip_address(host))+ except ValueError:+ pass++ try:+ return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host)))+ except OSError:+ pass++ return False+ class SpiderTools: """Tools for web scraping and crawling.""" @@ -59,31 +103,9 @@ def _validate_url(self, url: str) -> bool: 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']:+ if _host_is_blocked(parsed.hostname): return False- + return TrueAI-assisted change 709d91c773b0ec1b11054cf4ece7d0d77103cc40eb67bd3f3675374d2ea0119c · Fix bc46510981cda4ab16cf21073b8126afaf2bddebc58205036c9824e52d05c522