# 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/<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:

```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_<token>` 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_<token>` 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_<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.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_<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 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.
