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
- Operator runs the Jobs API without
PRAISONAI_JOBS_API_KEY (default → unauth).
- Attacker
POST /api/v1/runs with webhook_url = a rebinding domain.
- 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
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.
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.
Summary
PraisonAI's Async Jobs API enables its API-key middleware only when
PRAISONAI_JOBS_API_KEYis set, so by default every endpoint is unauthenticated. An unauthenticatedPOST /api/v1/runsaccepts an attacker-controlledwebhook_url; on job completion the server POSTs the job payload to it viahttpx. Thewebhook_urlhas an SSRF validator (gethostbyname+ private-IP check), but it validates at request time whilehttpxre-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
praisonai4.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.pyFunction:
create_appSnippet:
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.pyFunction:
submit_jobSnippet:
Issue: attacker-controlled
webhook_urlflows into the job with no authentication on the endpoint.Path:
src/praisonai/praisonai/jobs/models.py(validator) andsrc/praisonai/praisonai/jobs/executor.py(sink)Function:
validate_webhook_url→_send_webhookSnippet:
Issue: the validator resolves the hostname at validation time but does not pin the IP for the
httpx.postconnection → DNS rebinding (independent resolution at check vs connect) bypasses it and reaches internal services.Attack flow
PRAISONAI_JOBS_API_KEY(default → unauth).POST /api/v1/runswithwebhook_url= a rebinding domain.httpx.postre-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_urlvalidator 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.1limits 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
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.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
webhook_url: resolve once, reject private/loopback/CGNAT/metadata, then pin and connect to the validated IP; disable redirects.