---
id: CVE-2026-60091
aliases:
  - GHSA-4w49-gwv8-fpjg
title: >-
  PraisonAI: Jobs API is unauthenticated by default and allows
  attacker-controlled webhook SSRF
summary: >-
  PraisonAI: Jobs API is unauthenticated by default and allows
  attacker-controlled webhook SSRF
severity: high
cvss: 7.2
cwe:
  - CWE-200
  - CWE-306
  - CWE-367
  - CWE-918
vendor: praisonai
product: praisonai
ecosystem: pip
affected:
  - praisonai <= 4.6.77
patched:
  - praisonai 4.6.78
published: '2026-10-08'
updated: '2026-10-08'
sourceUpdated: '2026-10-08T21:57:57Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-4w49-gwv8-fpjg'
references:
  - url: >-
      https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-4w49-gwv8-fpjg
  - url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-60091'
  - url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-62175'
  - url: >-
      https://www.vulncheck.com/advisories/praisonai-before-unauthenticated-ssrf-via-webhook-url
  - url: 'https://github.com/advisories/GHSA-4w49-gwv8-fpjg'
tags:
  - ghsa
  - pip
epss: 0.00301
epssPercentile: 0.209
ingestedAt: '2026-10-08T22:11:53.881Z'
---

## Overview

## Summary

PraisonAI's Async Jobs API enables its API-key middleware **only when `PRAISONAI_JOBS_API_KEY` is set**, so by default every endpoint is unauthenticated. An unauthenticated `POST /api/v1/runs` accepts an attacker-controlled `webhook_url`; on job completion the server POSTs the job payload to it via `httpx`. The `webhook_url` has an SSRF validator (`gethostbyname` + private-IP check), but it validates at request time while `httpx` re-resolves at connection time — a DNS-rebinding TOCTOU that reaches internal services. Runtime-confirmed as an **unauthenticated, blind** SSRF (the internal canary received the POST; the internal response is not returned to the attacker). Severity Medium.

## Details

### Affected component
- Package: `praisonai` 4.6.63. Files: `src/praisonai/praisonai/jobs/server.py`, `jobs/router.py`, `jobs/executor.py`, `jobs/models.py`.

### Vulnerable code / root cause

Path:
`src/praisonai/praisonai/jobs/server.py`

Function:
`create_app`

Snippet:
```python
jobs_api_key = os.environ.get("PRAISONAI_JOBS_API_KEY")
# ...
if jobs_api_key:
    app.add_middleware(JobsAPIKeyMiddleware)   # auth ONLY when env var is set
```
Issue: with the env var unset (default), no auth middleware is added → all endpoints unauthenticated. Default bind is `127.0.0.1`.

Path:
`src/praisonai/praisonai/jobs/router.py`

Function:
`submit_job`

Snippet:
```python
@router.post("", response_model=JobSubmitResponse, status_code=202)
async def submit_job(request, response, body: JobSubmitRequest, ...):
    # no auth dependency; body.webhook_url is attacker-controlled
    job = Job(prompt=body.prompt, webhook_url=body.webhook_url, ...)
    await executor.submit(job)
```
Issue: attacker-controlled `webhook_url` flows into the job with no authentication on the endpoint.

Path:
`src/praisonai/praisonai/jobs/models.py` (validator) and `src/praisonai/praisonai/jobs/executor.py` (sink)

Function:
`validate_webhook_url` → `_send_webhook`

Snippet:
```python
# models.py validate_webhook_url (CHECK time)
ip = socket.gethostbyname(hostname)
if ipaddress.ip_address(ip).is_private or ...:
    raise ValueError("Webhook URL resolves to a private or restricted network address")

# executor.py _send_webhook (CONNECT time, re-resolves, no pinning)
async with httpx.AsyncClient(timeout=30.0) as client:
    response = await client.post(job.webhook_url, json=payload, ...)
```
Issue: the validator resolves the hostname at validation time but does not pin the IP for the `httpx.post` connection → DNS rebinding (independent resolution at check vs connect) bypasses it and reaches internal services.

### Attack flow
1. Operator runs the Jobs API without `PRAISONAI_JOBS_API_KEY` (default → unauth).
2. Attacker `POST /api/v1/runs` with `webhook_url` = a rebinding domain.
3. The validator(s) see a public IP (pass); on completion `httpx.post` re-resolves to an internal IP and connects → SSRF to internal.

### Why existing protection is bypassed
Auth is opt-in (only when the env var is set). The `webhook_url` validator resolves at check time but does not pin the IP for the connection → DNS-rebinding TOCTOU. (Correction to an earlier static note: the guard exists but is bypassable.)

### Security boundary
Unauthenticated network peer → server-side request to internal services (Scope: Changed). Default bind `127.0.0.1` limits remote reach unless the operator binds non-loopback.

## Proof of Concept

### Environment
Real Jobs API (`python -m praisonai.jobs.server`, no API key) in a local runtime (`127.0.0.1:18085`), resolver pointed at the controlled rebinding DNS, internal canary Docker-internal only. Runnable assets: `PraisonAI-Runtime-Repro\runtime-files\` (`docker-compose.jobs.yml`).

### Steps to reproduce
1. `PRAI-04-01-Jobs-Submit-NoAuth`: `POST /api/v1/runs {"prompt":"hello"}` → `202` (no auth).
2. `PRAI-04-02-Jobs-Webhook-SSRF-Blind`: `POST /api/v1/runs {"prompt":"hello","webhook_url":"http://rebind.lab:8081/secret"}` → `202`.

### Expected result
Unauthenticated job submission should be rejected; the webhook SSRF guard should prevent reaching internal services regardless of DNS timing.

## Impact
Unauthenticated job submission (LLM cost abuse); SSRF to internal services (blind, DNS-rebinding); exfiltration of the job result to an attacker-controlled webhook.

## Suggested remediation
- Authenticate the Jobs API by default (auto-generate a token / fail closed when binding non-loopback), like the gateway.
- For `webhook_url`: resolve once, reject private/loopback/CGNAT/metadata, then pin and connect to the validated IP; disable redirects.

## Affected packages

- `praisonai <= 4.6.77`

## Remediation

Upgrade to a patched release:

- `praisonai 4.6.78`
