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.
praisonai 4.6.63. Files: src/praisonai/praisonai/jobs/server.py, jobs/router.py, jobs/executor.py, jobs/models.py.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.
PRAISONAI_JOBS_API_KEY (default → unauth).POST /api/v1/runs with webhook_url = a rebinding domain.httpx.post re-resolves to an internal IP and connects → SSRF to internal.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.)
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.
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).
PRAI-04-01-Jobs-Submit-NoAuth: POST /api/v1/runs {"prompt":"hello"} → 202 (no auth).PRAI-04-02-Jobs-Webhook-SSRF-Blind: POST /api/v1/runs {"prompt":"hello","webhook_url":"http://rebind.lab:8081/secret"} → 202.Unauthenticated job submission should be rejected; the webhook SSRF guard should prevent reaching internal services regardless of DNS timing.
Unauthenticated job submission (LLM cost abuse); SSRF to internal services (blind, DNS-rebinding); exfiltration of the job result to an attacker-controlled webhook.
webhook_url: resolve once, reject private/loopback/CGNAT/metadata, then pin and connect to the validated IP; disable redirects.{
"cwe_ids": [
"CWE-200",
"CWE-306",
"CWE-367",
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-08T21:57:57Z",
"nvd_published_at": "2026-07-15T14:18:35Z",
"severity": "HIGH"
}