CVE-2026-49869: Verified Reproduction
CVE-2026-49869: Kestra unauthenticated RCE via AuthenticationFilter path bypass
CVE-2026-49869 is verified against the affected target. Vulnerability class: RCE. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00247.
What Is CVE-2026-49869?
CVE-2026-49869 is a critical unauthenticated remote code execution vulnerability in Kestra OSS, caused by an authentication-filter bypass that lets attackers reach internal APIs and trigger command execution. Pruva reproduced it (reproduction REPRO-2026-00247).
CVE-2026-49869 Severity & CVSS Score
CVE-2026-49869 is rated critical severity, with a CVSS base score of 10.0 out of 10.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
How to Reproduce CVE-2026-49869
pruva-verify REPRO-2026-00247 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00247/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-49869
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
Unauthenticated HTTP requests to /api/v1/main/flows/configs and /api/v1/main/executions/trigger/configs/configs (paths ending in /configs) plus an attacker-supplied flow YAML containing arbitrary shell commands.
- AuthenticationFilter.doFilter uses request.getPath().endsWith("/configs") to whitelist the public config endpoint; setting a flow namespace and id to the literal 'configs' makes POST /api/v1/main/flows/configs and POST /api/v1/main/executions/trigger/configs/configs end with '/configs', bypassing Basic Auth. The created flow's shell task then runs an arbitrary OS command in the Kestra worker.
reproduction_steps.sh How the agent worked
Root Cause and Exploit Chain for CVE-2026-49869
Kestra OSS (before 1.0.45 and 1.3.21) ships an AuthenticationFilter that
whitelists the public configuration endpoint with a pure path-suffix test:
boolean isConfigEndpoint = request.getPath().endsWith("/configs") || ...
Because the check only tests whether the request path ends with the literal
/configs segment, any /api/v1/** request whose final path segment is
configs bypasses Basic Authentication — including requests where configs is
simply the value of a path variable (a flow namespace or flow id). An
unauthenticated remote attacker can therefore reach arbitrary authenticated
controllers. By creating a flow whose namespace and id are both the
literal configs, an attacker makes both the flow-create endpoint
(POST /api/v1/main/flows/configs) and the execution-trigger endpoint
(POST /api/v1/main/executions/trigger/configs/configs) end with /configs,
bypassing the filter end-to-end and achieving unauthenticated remote code
execution through a shell task inside the flow.
- Package/component affected:
webserver/src/main/java/io/kestra/webserver/filter/AuthenticationFilter.java(OSS Basic-Auth filter), and every controller under/api/v1/**that the filter protects. - Affected versions: Kestra OSS
< 1.0.45and< 1.3.21(the 1.3.x line before 1.3.21). The Enterprise Edition uses Micronaut Security (@Requires(property = "micronaut.security.enabled", notEquals = "true")excludes EE) and is not affected by this OSS filter. - Risk level: Critical. Unauthenticated, remotely reachable, and trivially exploitable. Consequences are arbitrary OS command execution on the Kestra worker (as the service account), plus unauthenticated read/write of any flow, execution, log, or namespace resource exposed under
/api/v1/**.
Impact Parity
- Disclosed/claimed maximum impact: Unauthenticated remote code execution (severity: critical).
- Reproduced impact from this run: Unauthenticated remote code execution — an attacker-controlled shell command ran inside the Kestra worker and wrote a marker file as the
kestraservice user, reached entirely through unauthenticated HTTP requests to the real running Kestra webserver. - Parity:
full. - Not demonstrated: N/A — the full claimed impact (unauthenticated RCE via the API) was demonstrated end-to-end on the real product.
Root Cause
AuthenticationFilter.doFilter runs for every request matching @Filter("/api/v1/**") (when kestra.server-type is WEBSERVER|STANDALONE and Micronaut Security is not enabled, i.e. OSS). It computes:
boolean isConfigEndpoint = request.getPath().endsWith("/configs")
|| ((request.getPath().endsWith("/basicAuth") || request.getPath().endsWith("/basicAuthValidationErrors"))
&& !basicAuthService.isBasicAuthInitialized());
and, if isConfigEndpoint (or an open-URL / management endpoint) is true, calls
chain.proceed(request) without verifying credentials. The legitimate
public endpoint is GET /api/v1/configs (MiscController). The intended check
was "is this the configs endpoint", but endsWith("/configs") accepts any path
that merely terminates in that segment.
The Kestra API is tenant-prefixed: FlowController is mapped at
/api/v1/{tenant}/flows and ExecutionController at /api/v1/{tenant}/executions
(OSS TenantAliasingRooter rewrites /api/v1/<x> to /api/v1/main/<x> when no
route matches directly). So a flow whose namespace=configs and id=configs
makes the create and trigger URIs end in /configs:
| Action | URI | Last segment | Filter decision |
|---|---|---|---|
| Create flow | POST /api/v1/main/flows/configs |
configs |
bypass → chain.proceed |
| Trigger flow | POST /api/v1/main/executions/trigger/configs/configs |
configs |
bypass → chain.proceed |
Both reach the controller unauthenticated. The flow contains a
io.kestra.plugin.scripts.shell.Commands task (bundled in the official image)
using the io.kestra.plugin.core.runner.Process task runner, so the attacker's
shell command executes directly inside the Kestra worker process.
Fix commit: 28ff533d8 ("fix(auth): potential authentication bypass in the
authentication filter", GHSA-5vc5-wxxq-3fjx), released in v1.0.45 / v1.3.21. The
fix replaces the suffix test with an exact, normalized match:
String normalizedPath = normalizePath(request.getPath()); // path.replaceAll("/+", "/")
boolean isConfigEndpoint = "/api/v1/configs".equals(normalizedPath)
|| ((normalizedPath.matches("/api/v1(/[^/]+)?/basicAuth")
|| "/api/v1/basicAuthValidationErrors".equals(normalizedPath))
&& !basicAuthService.isBasicAuthInitialized());
The normalizePath step (collapsing //) also closes a related double-slash
evasion; the added regression tests assert that
/api/v1/main/flows/namespace/configs, /api/v1/main/namespace/basicAuth, and
/api/v1/main/basicAuthValidationErrors now return 401.
Reproduction Steps
- Script:
bundle/repro/reproduction_steps.sh(self-contained, idempotent). - What it does:
- Pulls the official Kestra images
kestra/kestra:v1.0.44(vulnerable) andkestra/kestra:v1.0.45(fixed). - Starts each image as a real
server standaloneinstance with an in-memory H2 backend and Basic Auth enabled (admin@kestra.io/Password1) — the sameAuthenticationFilterthat protects production deployments. - Against the vulnerable instance, with no credentials, it:
- sends a control request to a protected path that does not end in
/configs(expect401), - sends the same resource via a
/configs-suffixed path (expect not401— bypass), POST /api/v1/main/flows/configsto create an arbitrary flow (id=configs,namespace=configs) containing a shell task,POST /api/v1/main/executions/trigger/configs/configs?wait=trueto trigger it,- reads the marker file the shell command wrote inside the worker container.
- sends a control request to a protected path that does not end in
- Against the fixed instance it repeats the same unauthenticated bypass requests (expect
401for all) and confirms no marker is produced. - Writes
bundle/repro/runtime_manifest.jsonandbundle/logs/results_summary.txt.
- Pulls the official Kestra images
- Expected evidence of reproduction:
- Vulnerable: control=
401, bypass=404(filter bypassed, router reached), unauth create=200, unauth trigger=200, and a marker filePWNED_KESTRA_RCE_<token>written by the attacker command as thekestrauser. - Fixed: bypass=
401, unauth create=401, unauth trigger=401, no marker file.
- Vulnerable: control=
Evidence
bundle/logs/results_summary.txt— annotated HTTP status codes for every request on both versions and the final verdict flags.bundle/logs/vuln_create_flow_resp.txt—200response body returning the created flow (revision 1) with the attacker's shell task.bundle/logs/vuln_trigger_resp.txt—200response body returning the execution whose task run reachedSUCCESS.bundle/logs/vuln_marker.txt—ls -l+catof the marker file, owned bykestra:kestra, containing the run-uniquePWNED_KESTRA_RCE_<token>string produced by the injectedechocommand.bundle/logs/fixed_bypass_resp.txt,bundle/logs/fixed_create_flow_resp.txt,bundle/logs/fixed_trigger_resp.txt—401responses on the fixed version.bundle/logs/fixed_marker.txt— confirms no marker file exists on the fixed version.bundle/logs/vuln_container.log,bundle/logs/fixed_container.log— server startup logs (844 plugins registered; standalone server running).bundle/repro/malicious_flow.yaml— the exact flow source submitted unauthenticated.bundle/repro/runtime_manifest.json— structured runtime evidence (entrypoint_kind=api_remote,target_path_reached=true,rce_observed=true,fixed_blocks_bypass=true).
Key excerpt (vulnerable, unauthenticated):
[vuln] GET /api/v1/main/flows/configs/configs/revisions (no creds, no /configs suffix) -> 401 (expect 401: auth enforced)
[vuln] GET /api/v1/main/flows/configs/configs (no creds, /configs suffix) -> 404 (expect NOT 401: bypass)
[vuln] POST /api/v1/main/flows/configs (no creds, create flow) -> 200 (expect 200)
[vuln] POST /api/v1/main/executions/trigger/configs/configs?wait=true (no creds, trigger) -> 200 (expect 200)
[vuln] RCE CONFIRMED: marker file written by attacker command: PWNED_KESTRA_RCE_<token>
Key excerpt (fixed, negative control):
[fixed] GET .../flows/configs/configs (no creds, /configs suffix) -> 401 (expect 401: bypass closed)
[fixed] POST .../flows/configs (no creds, create flow) -> 401 (expect 401)
[fixed] POST .../trigger/configs/configs (no creds, trigger) -> 401 (expect 401)
[fixed] no marker file created (expected)
Environment: Official Docker images kestra/kestra:v1.0.44 and :v1.0.45
(eclipse-temurin 21 JRE, 844 bundled plugins). Standalone topology with
in-memory H2 queue/repository and local storage. Basic Auth enabled. All HTTP
requests were issued from inside the running Kestra container
(docker exec <container> curl http://localhost:8080/...) so the proof is
independent of the host/sandbox network topology. The OS command executed as the
kestra service account (uid 1000).
Recommendations / Next Steps
- Upgrade to Kestra OSS
>= 1.0.45or>= 1.3.21immediately. The fix replaces the suffix whitelist with an exact, slash-normalized equality check. - Defense in depth: do not rely on path-string suffix matching for authz decisions; match the exact public route(s) and prefer the framework's security interceptor / RBAC layer (Micronaut Security in EE) over hand-rolled filters.
- Restrict egress / sandbox task runners so that even if a filter bypass occurs, a compromised worker cannot trivially exfiltrate data or pivot. Consider running script tasks via the Docker task runner in an isolated, egress-controlled network rather than the in-process
Processrunner. - Monitoring: alert on unauthenticated
POSTactivity against/api/v1/**/flows/*and/api/v1/**/executions/trigger/*, and on flows whoseid/namespacecollide with whitelisted suffixes (configs,basicAuth,basicAuthValidationErrors). - Testing: add integration tests that assert every whitelisted "open" path is matched exactly (not by suffix) and that arbitrary
/configs-suffixed protected paths return401. The upstream regression tests added in the fix commit (configEndpointShouldBeCheckedExactly,basicAuthEndpointShouldBeCheckedExactly,basicAuthValidationErrorsEndpointShouldBeCheckedExactly) are good references.
Additional Notes
- Idempotency: the script removes any prior
kestra-vuln/kestra-fixedcontainers before starting, uses a run-unique marker token (PWNED_KESTRA_RCE_<epoch>_<pid>), and writes fresh evidence files each run. It has been verified to pass end-to-end (exit 0) on consecutive runs. - Scope of proof: the claim surface (
api_remote) and impact (code_execution) are both matched: the proof is a real unauthenticated HTTP request to the real running Kestra API that results in an attacker-controlled OS command executing in the worker. - Why the marker is read via
docker exec: the sandbox executing the script may itself be a container on a different Docker network than the Kestra container, so reaching127.0.0.1:8080from the script is not reliable. Issuing requests from inside the Kestra container (docker exec ... curl localhost:8080) makes the proof independent of the host/sandbox network topology while still exercising the real HTTP API boundary. - Limitations: the reproduction uses the bundled
io.kestra.plugin.core.runner.Processtask runner so that no Docker-in-Docker / Docker socket is required; a production deployment using the default Docker task runner would execute the same attacker command inside an isolated container, with the same RCE impact. ThetutorialFlows.enabled=falsesetting only suppresses sample-blueprint loading and does not affect the vulnerability.
CVE-2026-49869 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
Artifacts and Evidence for CVE-2026-49869
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-49869
FAQ: CVE-2026-49869
How does the Kestra AuthenticationFilter bypass work?
How severe is CVE-2026-49869?
How can I reproduce CVE-2026-49869?
References for CVE-2026-49869
Authoritative sources for CVE-2026-49869 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.