GHSA-fq2m-6wqh-x44g: PraisonAI: Jobs API exposes agent-execution endpoints with no authentication
praisonai: Jobs API exposes agent-execution endpoints with no authentication**Researcher:** Kai Aizen — SnailSploit (@SnailSploit), Adversarial & Offensive Security Research 
**Target:** https://github.com/MervinPraison/PraisonAI**Package:** `praisonai` on PyPI
**Affected version (empirically tested):** 4.6.48
**Components:**`praisonai.jobs.server.create_app` — `praisonai/jobs/server.py``praisonai.jobs.router.create_router` — `praisonai/jobs/router.py`Routes mounted at `/api/v1/runs/...`
**Weakness:** CWE-306 Missing Authentication for Critical Function · CWE-862 Missing Authorization · CWE-94 Code Injection (via prompt / agent_yaml).TL;DR`praisonai` ships a standalone async-jobs HTTP server (`python -m praisonai.jobs.server --host=0.0.0.0 --port=8005`) whose job is to accept job submissions and run agents on the operator's behalf. Every endpoint under `/api/v1/runs` is **unauthenticated**. There is no `auth_token` field, no `Depends(verify_*)`, no middleware that inspects `Authorization` — the CORS middleware *lists* `Authorization` in `allow_headers` (the only signal in the whole module that the developer was aware authentication is a thing), but no route ever reads it.A network-reachable attacker can:**Execute arbitrary agent code** — `POST /api/v1/runs` accepts `prompt`, `agent_yaml`, `agent_file`, `config`, `framework`. The job is queued and an executor invokes whichever framework (`praisonai` / `crewai` / `autogen`) the attacker picks, with whichever prompt and tool config the attacker supplies. The job runs in the operator's process — same environment variables, same filesystem, same credentials (OpenAI / Anthropic / Azure / Bedrock keys; tool integrations; on-disk YAML recipes).**List and read every job system-wide** — `GET /api/v1/runs` lists all jobs; `GET /api/v1/runs/{job_id}/result` returns the full result of any completed job. Operator's prompts, the agent's chain-of-thought, tool inputs / outputs, retrieved documents — all readable to an anonymous client.**Cancel or delete any job** — `POST /…/cancel` and `DELETE /…/{job_id}` accept arbitrary job IDs without any ownership / authorization check.**Stream live SSE of any in-flight job** — `GET /…/{job_id}/stream` reads the executor's live progress for any job ID.The remote-RCE shape (1) is the load-bearing one. Even with `webhook_url` SSRF-guarded (and it is — the model validator at `jobs/models.py:42-65` rejects localhost / private IPs), the attacker needs no callback: SSE streaming returns the agent's output directly on the same connection.Root causeThe same package gets auth right elsewhere (`praisonai/gateway/server.py` auto-generates an `auth_token` if none is configured and refuses to serve requests without it; `praisonai/endpoints/a2u_server.py:250-264` uses `hmac.compare_digest` on a Bearer token). The jobs API is the outlier.Empirically affected routesVerified by PoC against published `praisonai==4.6.48` (`/api/v1/runs/...` paths):| Method   | Path                          | Unauth result            |
|----------|-------------------------------|--------------------------|
| `POST`   | `/api/v1/runs`                | **HTTP 202 Accepted**, attacker job queued and executor invoked the framework |
| `GET`    | `/api/v1/runs`                | **HTTP 200**, lists every job in the store |
| `GET`    | `/api/v1/runs/{job_id}`       | **HTTP 200**, returns status of any job |
| `GET`    | `/api/v1/runs/{job_id}/result`| (untested; same router, no auth)         |
| `POST`   | `/api/v1/runs/{job_id}/cancel`| **HTTP 200 / 409** (processed)           |
| `DELETE` | `/api/v1/runs/{job_id}`       | **HTTP 204 No Content** (deleted)         |
| `GET`    | `/api/v1/runs/{job_id}/stream`| (untested; SSE; same router, no auth)    |PoC run log excerpt (`poc/run-log.txt`):The executor's error confirms the prompt reached the framework's LLM-invocation step. Had the operator set `OPENAI_API_KEY`, the attacker prompt would have executed.Impact details1. Remote code execution via agent invocation`JobSubmitRequest.framework` accepts `"praisonai"`, `"crewai"`, or `"autogen"`. Each framework can be configured (via the YAML / config the attacker sends) to use arbitrary tools. praisonai's tool loaders (`praisonai/agents_generator.py` `load_tools_from_module*`) have a documented history of arbitrary-import (CVE-2026-40287 and its fix-of-fix CVE-2026-44334). In practice the operator's installation may or may not expose these sinks; either way the attacker controls the prompt, which the LLM will execute with whatever tools the operator wired (including shell, filesystem, browser, …).The job executor runs in-process under the operator's service account, with full access to environment variables (LLM API keys, tool tokens) and to anything `praisonai`'s tools normally touch.2. Cross-tenant data readA single-process deployment uses an `InMemoryJobStore` that is flat — no `user_id` / `tenant_id` / `workspace_id` partition. Any client that knows or guesses a job ID can read it. Worse, the list endpoint (`GET /api/v1/runs`) returns every job, so guessing isn't even necessary.Sensitive content in the result includes the attacker's input (harmless) but also any *legitimate* user's input that the operator's backend submitted — and the agent's full output, which may contain data the agent retrieved from the operator's databases or APIs.3. Denial of service via job deletion / cancellation`DELETE` and `cancel` accept any job ID. An attacker who polls the list endpoint can enumerate IDs and cancel-then-delete every job in flight, breaking the operator's backend's polling-for-completion flow.4. webhook_url SSRF — defendedTo the developer's credit, `JobSubmitRequest.webhook_url` is validated against localhost / private / link-local / multicast IPs at submission time (`jobs/models.py:42-65`). This blocks the naive "submit a job whose webhook posts to AWS IMDS" attack. **Honest yield:** this is properly guarded.Anchorspraisonai 4.6.48, source file `praisonai/jobs/server.py` (sha256 `10b5deab96686f276b8ad71fa4712e1e3d301e4c356812d5d0d595b2b9503ef3`):| Line  | Symbol                                                  | What it shows |
|-------|---------------------------------------------------------|---------------|
| 59-152 | `def create_app(cors_origins, store, executor) -> FastAPI:` | Only middleware added is CORS; auth middleware absent. |
| 117   | `allow_headers=["Authorization", "Content-Type", "Origin", "Accept", "Idempotency-Key"]` | CORS hints that the operator should send Authorization — sole indicator the developer considered auth. |
| 124   | `jobs_router = create_router(get_store, get_executor)` | Router included without `dependencies=[…]`. |
| 178   | `"praisonai.jobs.server:create_app"` (passed to `uvicorn.run`) | Production-ready binding via the CLI / `start_server`. |praisonai 4.6.48, source file `praisonai/jobs/router.py` (sha256 `869564d523c14624afefb211a2e7c6bf8a27b3356bd19a58927fcb5e1ebb014c`):| Line  | Symbol                                                              | What it shows |
|-------|---------------------------------------------------------------------|---------------|
| 30-31 | `def create_router(store, executor) -> APIRouter:`                  | Sole entry point; no `dependencies=[Depends(...)]`. |
| 43    | `@router.post("", response_model=JobSubmitResponse, status_code=202)` | submit_job — no auth. |
| 109   | `@router.get("", response_model=JobListResponse)`                   | list_jobs — no auth. |
| 148   | `@router.get("/{job_id}", response_model=JobStatusResponse)`        | get_job_status — no auth. |
| 161   | `@router.get("/{job_id}/result", response_model=JobResultResponse)` | get_job_result — no auth. |
| 180   | `@router.post("/{job_id}/cancel", response_model=JobStatusResponse)`| cancel_job — no auth. |
| 205   | `@router.delete("/{job_id}", status_code=204)`                       | delete_job — no auth. |
| 224   | `@router.get("/{job_id}/stream")`                                    | stream_job (SSE) — no auth. |Suggested fixAdd a single FastAPI dependency that reads an `Authorization: Bearer <token>` header and `hmac.compare_digest`s it against an operator-configured secret. Apply it as a global router dependency:A startup-time refusal in `create_app` would round it out:The pattern is already present in the sibling `praisonai/gateway/server.py` (which auto-generates a random token if none is supplied) — that approach plus a logged warning about the new token would minimize operator friction.Steps to reproduceClone the target: `git clone --depth 1 https://github.com/MervinPraison/PraisonAI`Run the proof of concept (`poc.py`) against the cloned source.Observe the result shown under *Verified result* below.Proof of concept`poc.py`Verification harness (executed against the cloned repo)This drives the unmodified upstream code rather than a reproduction.Verified resultThis PoC was executed against the live upstream code; captured output:CreditKai Aizen — SnailSploit (@SnailSploit). Adversarial & Offensive Security Research.
CVSS v3.1: 9.8