# REPRO-2026-00247: Kestra unauthenticated RCE via AuthenticationFilter path bypass ## Summary Status: published Severity: critical Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00247 CVE: CVE-2026-49869 ## Package Name: Unknown Ecosystem: Unknown Affected: Unknown Fixed: Unknown ## Root Cause # RCA Report — CVE-2026-49869 ## Summary 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: ```java 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. ## Impact - **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.45` and `< 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 `kestra` service 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: ```java 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/` to `/api/v1/main/` 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: ```java 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 1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained, idempotent). 2. **What it does:** 1. Pulls the official Kestra images `kestra/kestra:v1.0.44` (vulnerable) and `kestra/kestra:v1.0.45` (fixed). 2. Starts each image as a real `server standalone` instance with an in-memory H2 backend and Basic Auth enabled (`admin@kestra.io` / `Password1`) — the same `AuthenticationFilter` that protects production deployments. 3. Against the **vulnerable** instance, with **no credentials**, it: - sends a control request to a protected path that does *not* end in `/configs` (expect `401`), - sends the same resource via a `/configs`-suffixed path (expect *not* `401` — bypass), - `POST /api/v1/main/flows/configs` to create an arbitrary flow (`id=configs`, `namespace=configs`) containing a shell task, - `POST /api/v1/main/executions/trigger/configs/configs?wait=true` to trigger it, - reads the marker file the shell command wrote inside the worker container. 4. Against the **fixed** instance it repeats the same unauthenticated bypass requests (expect `401` for all) and confirms no marker is produced. 5. Writes `bundle/repro/runtime_manifest.json` and `bundle/logs/results_summary.txt`. 3. **Expected evidence of reproduction:** - Vulnerable: control=`401`, bypass=`404` (filter bypassed, router reached), unauth create=`200`, unauth trigger=`200`, and a marker file `PWNED_KESTRA_RCE_` written by the attacker command as the `kestra` user. - Fixed: bypass=`401`, unauth create=`401`, unauth trigger=`401`, no marker file. ## 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` — `200` response body returning the created flow (revision 1) with the attacker's shell task. - `bundle/logs/vuln_trigger_resp.txt` — `200` response body returning the execution whose task run reached `SUCCESS`. - `bundle/logs/vuln_marker.txt` — `ls -l` + `cat` of the marker file, owned by `kestra:kestra`, containing the run-unique `PWNED_KESTRA_RCE_` string produced by the injected `echo` command. - `bundle/logs/fixed_bypass_resp.txt`, `bundle/logs/fixed_create_flow_resp.txt`, `bundle/logs/fixed_trigger_resp.txt` — `401` responses 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_ ``` 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 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.45` or `>= 1.3.21` immediately. 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 `Process` runner. - **Monitoring:** alert on unauthenticated `POST` activity against `/api/v1/**/flows/*` and `/api/v1/**/executions/trigger/*`, and on flows whose `id`/`namespace` collide 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 return `401`. 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-fixed` containers before starting, uses a run-unique marker token (`PWNED_KESTRA_RCE__`), 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 reaching `127.0.0.1:8080` from 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.Process` task 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. The `tutorialFlows.enabled=false` setting only suppresses sample-blueprint loading and does not affect the vulnerability. ## Reproduction Details Reproduced: 2026-07-06T08:36:20.898Z Duration: 1585 seconds Tool calls: 257 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00247 pruva-verify CVE-2026-49869 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00247&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00247/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-49869 - Source: kestra-io/kestra ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 14643 bytes) - bundle/repro/rca_report.md (analysis, 12126 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 21147 bytes) - bundle/vuln_variant/rca_report.md (analysis, 15231 bytes) - bundle/artifact_promotion_manifest.json (other, 11912 bytes) - bundle/logs/variant_results_summary.txt (other, 2152 bytes) - bundle/logs/variant_vuln_marker.txt (other, 110 bytes) - bundle/logs/variant_vuln_trigger_resp.txt (other, 709 bytes) - bundle/logs/variant_fixed_webhook_resp.txt (other, 283 bytes) - bundle/vuln_variant/source_identity.json (other, 1916 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 3267 bytes) - bundle/repro/runtime_manifest.json (other, 1220 bytes) - bundle/repro/validation_verdict.json (other, 1188 bytes) - bundle/logs/results_summary.txt (other, 1071 bytes) - bundle/logs/vuln_marker.txt (other, 103 bytes) - bundle/logs/vuln_create_flow_resp.txt (other, 580 bytes) - bundle/logs/vuln_trigger_resp.txt (other, 1589 bytes) - bundle/repro/malicious_flow.yaml (other, 257 bytes) - bundle/logs/vuln_container.log (log, 10219 bytes) - bundle/logs/fixed_bypass_resp.txt (other, 0 bytes) - bundle/logs/fixed_create_flow_resp.txt (other, 0 bytes) - bundle/logs/fixed_trigger_resp.txt (other, 0 bytes) - bundle/logs/fixed_marker.txt (other, 74 bytes) - bundle/logs/fixed_container.log (log, 10218 bytes) - bundle/vuln_variant/validation_verdict.json (other, 4260 bytes) - bundle/vuln_variant/variant_manifest.json (other, 4814 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 9291 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 1618 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00247 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00247/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00247 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev