Scope and safety. This article analyzes a public vulnerability record I reported, together with its defensive implications. The reproduction shown below is the local, non-destructive status-code check published in the advisory; it contains no weaponized payload, no exploitation of third-party systems, and no instructions for attacking deployments you do not own.

Executive summary

CVE-2026-55533 is an authentication fail-open condition in the PraisonAI Recipe HTTP server, affecting versions before 4.6.58. The server supports three authentication modes — none, api-key, and jwt. When an operator selects api-key or jwt but the corresponding secret is absent, the middleware does not reject the request and does not refuse to start. It simply passes the request through.

The result is a server that reports itself as authenticated while behaving exactly like auth: none. The condition is made externally reachable by a second defect: the CLI safety check that guards non-localhost binding verifies only that the mode name is not "none". It never verifies that a usable credential exists. A command such as praisonai recipe serve --host 0.0.0.0 --auth api-key therefore passes the guard, binds to every interface, and accepts anonymous requests.

The issue is classified as CWE-287: Improper Authentication and CWE-306: Missing Authentication for Critical Function. The GitHub advisory rates it High, with the CVSS 3.1 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N — a base score of 8.2 by the CVSS 3.1 formula.

PraisonAI fixed the issue in 4.6.58. Operators should upgrade, then confirm that a real secret is present in every environment where authentication is nominally enabled.

Evidence labels used below:

  • Verified means stated by the linked CVE, GitHub advisory, or project release material.
  • Inferred means a deployment or architectural consequence derived from those facts and clearly identified as such.

Key facts

Item Finding Confidence
Identifier CVE-2026-55533 Verified
Advisory GHSA-gfq8-hmph-9gjv Verified
Reporter hoanggxyuuki Verified (advisory credits)
Analyst NguyenHuyTrung Verified (advisory credits)
Package PraisonAI (pip) Verified
Affected versions Versions before 4.6.58 Verified
Fixed version 4.6.58 Verified
Weakness Improper authentication (CWE-287), missing authentication (CWE-306) Verified
Component Recipe HTTP server, src/praisonai/praisonai/recipe/serve.py Verified
Secondary component CLI serve guard, src/praisonai/praisonai/cli/features/recipe.py Verified
Trigger condition auth set to api-key or jwt with no key or secret configured Verified
Exposed endpoints POST /v1/recipes/run, /v1/recipes/stream, /v1/recipes/validate, and POST /admin/reload when admin is enabled Verified
Severity High, AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N Verified (vector); score 8.2 derived from the vector
EPSS 26th percentile at time of publication Verified
Fix commit MervinPraison/PraisonAI@2f9677a Verified

Why fail-open authentication is worse than no authentication

An unauthenticated service is at least honest about what it is. An operator who runs a service with auth: none knows they are responsible for every layer of protection in front of it, and will usually put the service behind a network boundary and treat it accordingly.

A fail-open service breaks that contract. The operator selects an authentication mode, the process starts without complaint, the configuration file and the deployment manifest both say auth: api-key, and every audit artifact agrees that the endpoint is protected. Nothing in the running system contradicts the belief — until an anonymous request succeeds.

Three properties make this class especially damaging:

  • It is silent. There is no warning log, no startup failure, and no different response shape. The only observable difference is that a request without a credential returns 200 instead of 401.
  • It is invisible to configuration review. Reading the deployment manifest confirms the intent, not the effect. A reviewer sees --auth api-key and stops looking.
  • It can appear after a successful deployment. Inferred: the secret does not have to be forgotten at design time. A rotated key that is never re-injected, a Kubernetes Secret that fails to mount, a .env file missing from a rebuilt image, or a CI variable scoped to the wrong environment all produce the same outcome — a service that was authenticated yesterday and is anonymous today, with no signal that anything changed.

For an agent framework the stakes are higher than for a typical CRUD API, because the endpoints that fall open do not return data — they execute work.

Root-cause analysis

The middleware allows the request when no secret exists

The Recipe server builds its authentication middleware in create_auth_middleware(). The API-key branch resolves the expected credential from an explicit argument or from the environment, and then makes a decision that determines the entire outcome of this CVE:

1
2
3
4
5
expected_key = api_key or os.environ.get("PRAISONAI_API_KEY")

if not expected_key:
# No key configured, allow request
return await call_next(request)

The JWT branch is structurally identical:

1
2
3
secret = jwt_secret or os.environ.get("PRAISONAI_JWT_SECRET")
if not secret:
return await call_next(request)

Read in isolation, each branch looks like a reasonable convenience: if nothing is configured, do not get in the user’s way. The comment even states the intent plainly. The problem is that “nothing is configured” is being treated as “the operator does not want authentication,” when the surrounding configuration has already established the opposite — the operator explicitly asked for api-key or jwt.

An absent secret is not a request to disable a control. It is a broken control, and a broken security control must fail closed.

The middleware is attached, which makes the failure convincing

The server only installs the middleware when a mode other than none is selected:

1
2
3
4
5
6
7
8
9
auth_type = config.get("auth")
if auth_type and auth_type != "none":
auth_middleware = create_auth_middleware(
auth_type,
api_key=config.get("api_key"),
jwt_secret=config.get("jwt_secret"),
)
if auth_middleware:
middleware.append(Middleware(auth_middleware))

This is worth noting because it means the middleware really is present in the ASGI stack. Anything that introspects the application — a startup log, a health check, a debugging session — will confirm that authentication middleware is installed. It is installed, mounted, and running. It just returns call_next(request) for every caller.

The CLI guard checks the label, not the capability

The path that turns a local weakness into a network-reachable one is the non-localhost safety check in cmd_serve:

1
2
3
if host != "127.0.0.1" and host != "localhost" and auth == "none":
self._print_error("Auth required for non-localhost binding. Use --auth api-key or --auth jwt")
return self.EXIT_POLICY_DENIED

The intent behind this guard is exactly right: do not let a user publish this service to every interface without authentication. The implementation checks the wrong predicate. It asks “has the operator named an authentication mode?” when the invariant it is trying to enforce is “will an anonymous request be rejected?”

Those two questions have the same answer in every case except the one that matters. So the following command passes the guard, prints no warning, and binds to all interfaces with no effective authentication:

1
praisonai recipe serve --host 0.0.0.0 --auth api-key

Verified: the advisory confirms that with no --api-key and no PRAISONAI_API_KEY in the environment, requests are still accepted.

The two defects compose

Neither defect is maximally severe alone. The middleware fail-open on a loopback-bound server is a local exposure. The CLI guard is only a guard — it protects nothing on its own. Together they form a complete path: the guard grants permission to bind publicly on the strength of a control that the middleware has quietly disabled.

1
2
3
4
5
6
7
8
9
10
11
12
13
Operator intent:  "expose the recipe server, but require an API key"
|
v
CLI guard auth != "none"? -> yes -> allow 0.0.0.0 bind
| (never checks for a secret)
v
Middleware expected_key set? -> no -> call_next(request)
| (never rejects)
v
Anonymous network client ----> POST /v1/recipes/run
POST /v1/recipes/stream
POST /v1/recipes/validate
POST /admin/reload (when enable_admin)

Reproduction: a status-code differential

The published proof of concept is deliberately boring, and that is the point. It does not exploit anything. It builds the application in-process with Starlette’s TestClient, with the relevant environment variables removed, and compares status codes across three configurations.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Ensure no secrets are present in the environment.
os.environ.pop("PRAISONAI_API_KEY", None)
os.environ.pop("PRAISONAI_JWT_SECRET", None)

from praisonai.recipe.serve import create_app

# api-key auth selected, but no key configured.
app_open = create_app({"auth": "api-key", "enable_admin": True})

# api-key auth selected with an actual key configured.
app_closed = create_app({"auth": "api-key", "api_key": "expected", "enable_admin": True})

# jwt auth selected, but no JWT secret configured.
app_jwt_open = create_app({"auth": "jwt"})

The observed behaviour on an affected version:

Configuration Request Status
auth: api-key, no key GET /openapi.json 200
auth: api-key, no key POST /admin/reload 200
auth: api-key, key set POST /admin/reload, no key sent 401
auth: api-key, key set POST /admin/reload, wrong key 401
auth: api-key, key set POST /admin/reload, correct key 200
auth: jwt, no secret GET /openapi.json 200

The middle three rows are the control group: with a key configured, the middleware rejects correctly, which proves the enforcement logic itself is sound. The failure is entirely in the decision about what to do when the secret is missing. A single test asserting 401 for the no-secret case would have caught this, which is the practical lesson at the end of this article.

Impact and deployment-dependent blast radius

Verified impact

  • An unauthenticated attacker can reach Recipe server endpoints on a deployment where the operator selected api-key or jwt authentication.
  • Recipe execution endpoints — POST /v1/recipes/run and POST /v1/recipes/stream — are reachable without credentials.
  • When enable_admin is set, POST /admin/reload is reachable without credentials.
  • The CVSS vector records network attack vector, low complexity, no privileges, and no user interaction, with High integrity impact and Low confidentiality impact.

The integrity-weighted vector is the correct reading. This is not primarily a data-disclosure bug; it is an unauthorized-execution bug. The attacker’s main capability is to make the server do things.

Deployment-dependent consequences

The following are inferred, and depend entirely on which recipes a given installation exposes and what those recipes are permitted to do:

  • invocation of arbitrary registered recipe workflows, including any that call tools, write files, or reach internal services;
  • consumption of the operator’s LLM provider quota and budget through unauthenticated recipe runs;
  • use of the server’s network position to reach services that trust it;
  • exposure of whatever recipe definitions, names, and parameter schemas the API surface reveals;
  • administrative reload operations that pick up changed recipe definitions, where the admin endpoint is enabled;
  • denial of service through resource exhaustion, though availability is rated A:N in the official vector.

The upper bound is set by the recipes themselves. A deployment whose recipes only summarize text is materially different from one whose recipes have filesystem or shell tooling attached — but in both cases the operator believed the endpoint required a credential, and it did not.

Safe validation and detection

Configuration review

  1. Inventory PraisonAI installations and record the version. Flag anything before 4.6.58.
  2. Find every place the Recipe server is started: shell scripts, systemd units, Dockerfiles, Compose files, Kubernetes manifests, CI jobs, and process supervisors.
  3. For each, extract the --host/host and --auth/auth values.
  4. For each invocation where auth is api-key or jwt, confirm that a secret is actually present in the environment the process runs in — not merely referenced in a template. A ${PRAISONAI_API_KEY} reference that resolves to an empty string is the vulnerable case.
  5. Treat any host other than 127.0.0.1, localhost, or ::1 as published until proven otherwise, including binds to 0.0.0.0 behind a network policy you have not read this quarter.

Non-destructive runtime check

On a system you own or are authorized to assess, the cheapest confirmation is a single credential-free request to a harmless endpoint:

1
2
# On your own deployment only. A 200 here means auth is not being enforced.
curl -s -o /dev/null -w '%{http_code}\n' http://<your-host>:<port>/openapi.json

401 means the control is effective. 200 on a server configured for api-key or jwt means it is not. No recipe is executed and no state is changed by this request.

Log and telemetry review

Look for:

  • successful 2xx responses to /v1/recipes/run, /v1/recipes/stream, or /admin/reload on requests that carried no Authorization or X-API-Key header;
  • requests to the recipe endpoints from source addresses outside the expected client set;
  • recipe executions that do not correlate with a known upstream job, schedule, or user session;
  • /admin/reload calls at any time they were not part of a deployment;
  • LLM provider usage or spend that does not match your own workload;
  • discovery-shaped traffic against /openapi.json followed by direct endpoint calls.

Inferred: if your access logs do not record whether an authentication header was present, add that field before you need it. Reconstructing which historical requests were anonymous is far harder after the fact.

Remediation and hardening

Required remediation

Upgrade to PraisonAI 4.6.58 or later, then verify — do not assume — that each environment has a real secret. The upgrade converts a silent pass-through into an enforced rejection, which means a deployment that has been quietly running without a secret will begin returning 401 after the upgrade. That is the fix working correctly. Plan for it: check every environment’s secret injection before the rollout rather than discovering the gap through a broken client.

Defense in depth

  • Bind the Recipe server to loopback and place an authenticated, fail-closed reverse proxy in front of it.
  • Do not publish agent-execution APIs directly to the internet.
  • Make the secret’s presence a startup precondition in your own orchestration: fail the container’s readiness check if the variable is empty, so a missing secret becomes a failed deploy instead of an open endpoint.
  • Alert on empty-but-required environment variables in your deployment pipeline.
  • Leave enable_admin off unless an administrative workflow genuinely requires it, and expose it on a separate, more tightly restricted path when it does.
  • Restrict callers by network policy and service identity, not only by credential.
  • Scope the runtime: non-root user, minimal mounts, restricted egress, and no ambient cloud credentials that recipes could reach.
  • Apply rate limits and per-run resource ceilings so that anonymous or stolen access cannot be converted into unbounded provider spend.
  • If you cannot rule out that a vulnerable version was reachable without a secret, rotate the credentials the process could access and review recipe execution history for the exposure window.

Network controls are compensating controls, not a substitute for the upgrade. An internal caller, a compromised neighbouring workload, or a misrouted ingress can all reach an endpoint that was never really protected.

Timeline

Date Event Source certainty
2026-06-13 GitHub shows the advisory published in MervinPraison/PraisonAI, alongside the v4.6.58 release containing fix commit 2f9677a. Primary advisory and release page
Around 2026-08-25 GitHub records publication to the GitHub Advisory Database, with review completed and metadata last updated around the same time. Advisory page, displayed as a relative timestamp at time of writing
2026-08-29 This analysis published. This site

Advisory records are reserved, published, reviewed, and enriched at different times, and GitHub displays some of these as relative timestamps that drift as the page is re-rendered. Read the live records for authoritative chronology; nothing here should be treated as the vendor-notification or discovery date.

Lessons for builders

Fail closed, always

A security control that cannot function must refuse to serve, not step aside. The single-line change in posture is:

1
2
3
4
5
6
7
# Vulnerable shape.
if not expected_key:
return await call_next(request)

# Correct shape.
if not expected_key:
return JSONResponse({"error": "Server misconfigured: auth enabled without a secret"}, status_code=500)

Returning 500 rather than 401 is deliberate here: the caller did nothing wrong, and the operator needs a distinguishable signal. Better still, refuse at startup — see below.

Validate configuration once, at startup, not per request

Per-request handling of a missing secret means the same broken state is re-evaluated thousands of times and never surfaces. Configuration is not request data: resolve it when the application is constructed, and raise if the combination is incoherent.

1
2
3
4
5
# Defensive pseudocode.
if auth_type in {"api-key", "jwt"} and not resolve_secret(auth_type, config):
raise ConfigurationError(
f"auth={auth_type} requires a secret; refusing to start"
)

A process that will not start is a loud, immediate, unmissable failure. A process that starts and silently accepts anonymous traffic is the worst possible outcome of the same mistake.

Guards must test the invariant they promise

The CLI check enforced auth != "none" while promising “auth required for non-localhost binding.” Whenever a guard’s condition and its error message describe different things, that gap is a bug waiting to be found. The guard should have called the same resolution function the middleware uses, and asked whether it produced a usable credential.

This generalizes: a check on a setting’s name is not a check on a capability. Enforce on the resolved value, not on the label the operator typed.

Test the negative path

Every one of the failing behaviours here is one assertion away from being caught:

1
2
3
def test_api_key_auth_without_key_rejects():
app = create_app({"auth": "api-key"}) # no key, deliberately
assert TestClient(app).post("/admin/reload").status_code != 200

Positive-path tests confirm that a correct credential works — and the control group in the reproduction above proves those tests passed. The missing test is the one that asserts what happens when the server itself is misconfigured. For any security control, write the test that describes what must never succeed.

Treat agent execution endpoints as privileged by default

A recipe endpoint is not a data endpoint. It runs workflows, spends money, touches tools, and may reach internal systems. Defaults for such surfaces should be conservative — loopback binding, admin off, and an explicit, loudly-confirmed decision required before anything becomes reachable outside a private control plane.

References

Public vulnerability records change as maintainers and databases add references or revise metadata. Recheck the primary sources above during remediation and incident response.