All vulnerabilities
CVE-2026-60091GHSA-4w49-gwv8-fpjgPYSEC-2026-4249

PraisonAI: Jobs API is unauthenticated by default and allows attacker-controlled webhook SSRF

MervinPraison praisonaipipLibraries & packages
CVSS
7.2High
ExploitationPublic exploit codeCISA's assessment says proof of concept code is public, assessed 10 Jul 2026. Not in the KEV catalogCISA via CVE List
Exploit odds (EPSS)0.30%Chance of exploitation activity in the next 30 days, higher than 21% of CVEs. Scored 23 Oct 2026FIRST EPSS
Fixed in4.6.78Fixed versionGitHub
Published10 Jul 2026Updated 15 Oct 2026CVE List

What fixes it

  • 4.6.78praisonai, pipGitHub
  • 4.6.78PraisonAICVE List
  • 4.6.78praisonai, PyPIOSVpip install "praisonai>=4.6.78"

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

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:

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:

@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:

# 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.

Text from GitHub Advisory Database

Affected versions 54

  • praisonai, pip<= 4.6.77GitHub
  • PraisonAIaffected: all versions before 4.6.78CVE List
  • PraisonAIunaffected: 4.6.78CVE List
  • praisonai, PyPIfrom first release, fixed in 4.6.78OSV
  • praisonai, PyPI0.0.1OSV
  • praisonai, PyPI0.0.10OSV
  • praisonai, PyPI0.0.11OSV
  • praisonai, PyPI0.0.12OSV
  • praisonai, PyPI0.0.13OSV
  • praisonai, PyPI0.0.14OSV
  • praisonai, PyPI0.0.15OSV
  • praisonai, PyPI0.0.16OSV
  • praisonai, PyPI0.0.17OSV
  • praisonai, PyPI0.0.18OSV
  • praisonai, PyPI0.0.19OSV
  • praisonai, PyPI0.0.2OSV
  • praisonai, PyPI0.0.20OSV
  • praisonai, PyPI0.0.21OSV
  • praisonai, PyPI0.0.22OSV
  • praisonai, PyPI0.0.23OSV
  • praisonai, PyPI0.0.24OSV
  • praisonai, PyPI0.0.25OSV
  • praisonai, PyPI0.0.26OSV
  • praisonai, PyPI0.0.27OSV
  • praisonai, PyPI0.0.28OSV
  • praisonai, PyPI0.0.29OSV
  • praisonai, PyPI0.0.3OSV
  • praisonai, PyPI0.0.30OSV
  • praisonai, PyPI0.0.31OSV
  • praisonai, PyPI0.0.32OSV
  • praisonai, PyPI0.0.33OSV
  • praisonai, PyPI0.0.34OSV
  • praisonai, PyPI0.0.35OSV
  • praisonai, PyPI0.0.36OSV
  • praisonai, PyPI0.0.37OSV
  • praisonai, PyPI0.0.38OSV
  • praisonai, PyPI0.0.39OSV
  • praisonai, PyPI0.0.4OSV
  • praisonai, PyPI0.0.40OSV
  • praisonai, PyPI0.0.41OSV
  • praisonai, PyPI0.0.42OSV
  • praisonai, PyPI0.0.43OSV
  • praisonai, PyPI0.0.44OSV
  • praisonai, PyPI0.0.45OSV
  • praisonai, PyPI0.0.46OSV
  • praisonai, PyPI0.0.47OSV
  • praisonai, PyPI0.0.48OSV
  • praisonai, PyPI0.0.49OSV
  • praisonai, PyPI0.0.5OSV
  • praisonai, PyPI0.0.50OSV
  • praisonai, PyPI0.0.52OSV
  • praisonai, PyPI0.0.53OSV
  • praisonai, PyPI0.0.54OSV
  • praisonai, PyPI0.0.55OSV

References 5