# Variant RCA Report — CVE-2026-49869

## Summary

A distinct **alternate trigger** for the same root-cause class was confirmed on
the vulnerable Kestra OSS **v1.0.44**: unauthenticated RCE via the
`AuthenticationFilter` **`isOpenUrl` whitelist branch** (`request.getPath().startsWith(openUrl)`),
which the fix commit `28ff533d8` did **not** modify. The parent repro exploited the
`isConfigEndpoint` branch (`endsWith("/configs")`) for **both** flow creation and
trigger (`/api/v1/main/executions/trigger/configs/configs`). This variant reuses the
`/configs` suffix bypass only for flow **creation**, then triggers the flow through
the **open webhook endpoint** `GET /api/v1/main/executions/webhook/configs/configs/<key>`
— a *different* whitelist branch and a *different* `ExecutionController` endpoint
(`/webhook` vs `/trigger`), where the trigger path ends with the attacker-chosen
webhook **key**, not `/configs`. On the **fixed v1.0.45**, the `/configs`
create-bypass is closed (`401`), so the attacker can no longer plant the flow and
no RCE occurs; however the open webhook trigger path itself remains open
(`404 "Flow not found"`, **not** `401`), proving the `isOpenUrl` branch is untouched
by the fix and persists on the latest v1.0.49. The open webhook is documented
behavior (webhook key = secret), so this is reported as an **alternate trigger +
defense-in-depth gap**, **not** a bypass.

## Fix Coverage / Assumptions

Fix `28ff533d8` ("fix(auth): potential authentication bypass in the authentication
filter", GHSA-5vc5-wxxq-3fjx) modifies **only** the `isConfigEndpoint` branch of
`AuthenticationFilter.doFilter`:

- `endsWith("/configs")` → `"/api/v1/configs".equals(normalizePath(path))` (exact).
- `endsWith("/basicAuth")` → regex `/api/v1(/[^/]+)?/basicAuth` (still gated by
  `!isBasicAuthInitialized()`).
- `endsWith("/basicAuthValidationErrors")` → exact equality (still setup-gated).
- New `normalizePath` collapses `//`.

**Invariants the fix relies on:**

- **A. Only `isConfigEndpoint` needed hardening** — the other two open branches in
  the same filter (`isOpenUrl`, `isManagementEndpoint`) were not touched.
- **B. Filter/router path consistency** after slash-normalization (exact equality
  cannot diverge from routing).
- **C. basicAuth branch is setup-only** (`!isBasicAuthInitialized()` gate) and the
  regex matches only the real `/{tenant}/basicAuth` setup route.
- **D. Flow CREATION is the gate** — once creation requires auth, an unauthenticated
  attacker cannot plant a malicious flow, so unauthenticated RCE is prevented.

**What the fix does NOT cover:** the `isOpenUrl` branch
(`request.getPath().startsWith(s)` for each `kestra.server.basic-auth.open-urls`
entry) is the same over-permissive **prefix** string-match class as the original
**suffix** bug, and the fix did not address it. The production-default `open-urls`
include `/api/v1/main/executions/webhook/`, so the webhook execution endpoint is
unauthenticated on every version including fixed/latest. Assumption D still holds
on the fixed version (creation is blocked), but the trigger layer provides **no
defense-in-depth**: any future re-bypass of flow creation would immediately expose
unauthenticated RCE via the open webhook, which the fix does not protect against.

## Variant / Alternate Trigger

**Entry points (unauthenticated HTTP):**

1. `POST /api/v1/main/flows/configs?delete=false` — create a flow with
   `namespace=configs`, `id=configs`, a `io.kestra.plugin.core.trigger.Webhook`
   trigger (attacker-chosen `key`), and a `io.kestra.plugin.scripts.shell.Commands`
   task. Path ends with `/configs` → `isConfigEndpoint` bypass (same create step as
   parent repro).
2. `GET /api/v1/main/executions/webhook/configs/configs/<key>` — trigger the flow
   via the **open-url webhook**. Path starts with `/api/v1/main/executions/webhook/`
   → `isOpenUrl` bypass (path ends with `/<key>`, **not** `/configs`). Reaches
   `ExecutionController.triggerExecutionByGetWebhook` → `webhook()` → flow shell
   task → RCE.

**Code path:**

- `AuthenticationFilter.doFilter` → `isOpenUrl` (`startsWith`) true → `chain.proceed`
  (no credential check). File:
  `webserver/src/main/java/io/kestra/webserver/filter/AuthenticationFilter.java:66-72`.
- `ExecutionController.triggerExecutionByGetWebhook` / `webhook(...)` →
  `flowRepository.findById(...)` → `webhook.evaluate(request, flow)` →
  `executionQueue.emit(result)` → worker runs the shell task. File:
  `webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java:493-620`.
- Default `open-urls` whitelisting `/api/v1/main/executions/webhook/`:
  `cli/src/main/resources/application.yml:166-173`.
- Webhook trigger (key = secret):
  `core/src/main/java/io/kestra/plugin/core/trigger/Webhook.java:102-120`.

**Rule-out matrix (true bypass on fixed, normal operation):**

- `/configs` exact-match edge cases on fixed: `/api/v1//configs` → **404**
  (whitelisted by `normalizePath` but unroutable → harmless); `/api/v1/configs/`
  (trailing slash) → **401**; `/api/v1/main/flows%2Fconfigs` (encoded slash) →
  **401**. No bypass.
- basicAuth regex (setup state, `!isBasicAuthInitialized()`):
  `GET /api/v1/flows/basicAuth` → **200** (whitelisted; routes via aliasing to
  FlowController `/{namespace}` read of namespace `basicAuth` → negligible);
  `GET /api/v1/main/namespace/basicAuth` (two segments) → **401** (fix's own test
  case); `POST /api/v1/flows/basicAuth` → **422** (routes to MiscController
  `createBasicAuth` setup endpoint, **not** FlowController create). No
  normal-operation bypass, no RCE.

## Impact

- **Package/component:** `io.kestra.webserver.filter.AuthenticationFilter`
  (`isOpenUrl` branch) + `io.kestra.webserver.controllers.api.ExecutionController`
  (webhook endpoints); default `open-urls` in `cli/.../application.yml`.
- **Affected versions (as tested):**
  - Vulnerable (alternate-trigger RCE confirmed): **v1.0.44** (`kestra/kestra:v1.0.44`,
    git `3cf9c7f5cc`).
  - Fixed (creation blocked, no RCE; open webhook latent): **v1.0.45**
    (`kestra/kestra:v1.0.45`, git `9225d093663`; fix `28ff533d8` is an ancestor).
  - Latest 1.0.x checked: **v1.0.49** — `isOpenUrl` + default open-urls are
    byte-identical to the fixed version (gap persists).
- **Risk level:** Critical on the vulnerable version (unauthenticated RCE, identical
  to the parent). On the fixed version the alternate-trigger chain is broken at the
  creation step; the residual open-webhook trigger is documented behavior but
  represents a defense-in-depth gap (no trigger-layer protection).

## Impact Parity

- **Disclosed/claimed maximum impact (parent):** Unauthenticated remote code
  execution (critical).
- **Reproduced impact from this variant run:**
  - Vulnerable v1.0.44: **full** — unauthenticated OS command execution in the
    Kestra worker (marker file written by the attacker `echo` command, owned by
    `kestra:kestra`), via a *different* trigger path than the parent repro.
  - Fixed v1.0.45: **none** — no RCE (creation blocked at `401`); open webhook
    returns `404`.
- **Parity (vulnerable):** `full`. **Parity (fixed):** `none` (not a bypass).
- **Not demonstrated:** RCE on the fixed version (by design — the fix closes
  creation); the latent open-webhook trigger surface is documented, not exploited.

## Root Cause

`AuthenticationFilter` implements **three** independent open-path whitelist
branches (`isConfigEndpoint`, `isOpenUrl`, `isManagementEndpoint`); any one being
true skips Basic-Auth and calls `chain.proceed`. The original CVE abused the
`isConfigEndpoint` **suffix** test. The fix hardened *only* `isConfigEndpoint`
(exact match + `normalizePath`). The `isOpenUrl` branch uses a **prefix** test
(`startsWith`) over the production-default `open-urls`, which include
`/api/v1/main/executions/webhook/`; that prefix matches the `ExecutionController`
webhook trigger routes, leaving them unauthenticated on every version. Because the
webhook trigger is a documented public trigger (key = secret), the open webhook is
not itself a flaw — but it is the *same over-permissive string-match whitelist
class* in the *same filter*, and it is the mechanism by which an attacker-created
flow (planted via the `/configs` create-bypass on the vulnerable version) is
triggered unauthenticated through a *different* endpoint than the parent repro. Fix
commit: `28ff533d85e6c977e209f90e87e8523d0f8888eb`.

## Reproduction Steps

1. Script: `bundle/vuln_variant/reproduction_steps.sh`.
2. What it does (idempotent, runs both versions side by side from the official
   Docker images, with the production-default `open-urls` so the webhook is open as
   in real deployments):
   - **Vulnerable v1.0.44 (basic auth configured = normal operation):**
     1. Control `GET /api/v1/main/flows/configs/configs/revisions` (no creds) → `401`.
     2. Create the webhook-equipped malicious flow via the `/configs` bypass
        `POST /api/v1/main/flows/configs` (no creds) → `200`.
     3. **Variant trigger** `GET /api/v1/main/executions/webhook/configs/configs/<key>`
        (no creds; path ends with `/<key>`, not `/configs`) → `200` and a marker
        file written by the attacker shell command.
   - **Fixed v1.0.45 (normal operation):**
     1. Create flow via `/configs` bypass → `401` (fix blocks creation).
     2. Open webhook path check `GET /api/v1/main/executions/webhook/configs/configs/<key>`
        → `404` (not `401`) — `isOpenUrl` branch still open, flow not found.
     3. `/configs` exact-match edge cases (`//configs`, trailing slash, `%2F`) →
        `404`/`401` (no bypass).
   - **Fixed v1.0.45 (setup state, no basic auth):** basicAuth regex rule-out.
3. Expected evidence: vulnerable webhook trigger returns `200` with an execution
   whose state reaches `SUCCESS` and a marker `VARIANT_KESTRA_RCE_<token>` owned by
   `kestra:kestra`; fixed returns `401` for creation and `404` for the webhook (no
   marker).

## Evidence

- `bundle/logs/variant_results_summary.txt` — annotated HTTP status codes for every
  request on both versions + verdict flags.
- `bundle/logs/variant_vuln_marker.txt` — `ls -l` + `cat` of the marker file
  (`-rw-r--r-- 1 kestra kestra ... VARIANT_KESTRA_RCE_<token>`).
- `bundle/logs/variant_vuln_trigger_resp.txt` — `200` webhook response body: an
  execution with `flowId:configs, namespace:configs, trigger:{id:webhook,
  type:io.kestra.plugin.core.trigger.Webhook}`, `state.current:SUCCESS`.
- `bundle/logs/variant_vuln_create_flow_resp.txt` — `200` flow-create response.
- `bundle/logs/variant_fixed_webhook_resp.txt` — `404 "Not Found: Flow not found"`
  (not `401`), confirming `isOpenUrl` bypassed auth on the fixed version.
- `bundle/logs/variant_fixed_create_flow_resp.txt` — `401` (creation blocked by fix).
- `bundle/logs/variant_fixed_marker.txt` — no marker on fixed.
- `bundle/logs/variant_{vuln,fixed}_container.log` — server startup logs.
- `bundle/vuln_variant/runtime_manifest.json` — structured runtime evidence.
- `bundle/vuln_variant/source_identity.json` — exact tested commits/versions.

**Key excerpt (vulnerable, unauthenticated alternate trigger):**
```
[vuln] POST /api/v1/main/flows/configs (no creds, /configs suffix, create webhook flow) -> 200
[vuln] GET /api/v1/main/executions/webhook/configs/configs/variantSecretKey2026 (no creds, OPEN-URL webhook, ends with /<key> NOT /configs) -> 200
[vuln] ALTERNATE-TRIGGER RCE CONFIRMED via open-webhook (isOpenUrl branch): marker=VARIANT_KESTRA_RCE_<token>
```
**Key excerpt (fixed, negative):**
```
[fixed] POST /api/v1/main/flows/configs (no creds, create) -> 401
[fixed] GET /api/v1/main/executions/webhook/configs/configs/variantSecretKey2026 (no creds) -> 404
[fixed] no marker file created (expected: creation blocked)
```

**Environment:** Official Docker images `kestra/kestra:v1.0.44` and `:v1.0.45`
(eclipse-temurin 21 JRE, 844 bundled plugins). Standalone H2 in-memory. Basic Auth
enabled (normal operation) and a separate setup-state instance (no basic auth).
Production-default `open-urls` applied. All HTTP requests issued from inside the
running Kestra container (`docker exec ... curl localhost:8080`) for network-topology
independence. The OS command executed as the `kestra` service account.

## Recommendations / Next Steps

1. **The fix is correct and complete for the disclosed CVE** (unauthenticated RCE
   via the `/configs` suffix in normal operation). No change needed to the
   `isConfigEndpoint` hardening.
2. **Defense in depth at the trigger layer:** recognize that
   `AuthenticationFilter` has multiple whitelist branches and that `isOpenUrl`
   (`startsWith`) leaves the webhook execution endpoint unauthenticated by default.
   The mitigation should NOT rely solely on blocking flow creation:
   - Treat webhook keys as critical secrets (enforce minimum entropy / random
     generation; the Webhook schema already warns about this).
   - Consider gating webhook-triggered execution of flows that contain
     code-execution tasks (shell/scripts) behind an additional check, or require
     webhook flows to be explicitly marked as "publicly triggerable" by an
     authenticated admin.
   - Optionally tighten `isOpenUrl` to exact/route-aware matching instead of raw
     `startsWith`, so a misconfigured or future open-url prefix cannot accidentally
     expose more than the intended webhook route.
3. **Monitoring:** alert on unauthenticated `POST/GET` to
   `/api/v1/**/executions/webhook/**` for flows containing shell/script tasks, and
   on flows whose `id`/`namespace` collide with whitelisted suffixes
   (`configs`, `basicAuth`, `basicAuthValidationErrors`).
4. **Regression tests:** extend the fix's test suite to assert that the `isOpenUrl`
   branch cannot be used to reach non-webhook authenticated endpoints, and that
   webhook triggering of a code-execution flow is auditable.

## Additional Notes

- **Idempotency:** the script removes prior containers, uses a run-unique marker
  token, and writes fresh evidence each run. Verified to run twice consecutively
  with identical results (exit 1 = alternate trigger on vulnerable, not a bypass on
  fixed).
- **Bypass vs alternate trigger:** this is an **alternate trigger** (vulnerable
  only), **not a bypass**. On the fixed version the attacker cannot create the flow
  (`401`), so the open webhook has nothing to trigger (`404`, no RCE).
- **Documented-behavior caveat:** the webhook endpoint being unauthenticated is
  documented and accepted (the webhook key is the secret). The variant's
  CVE-relevance comes from pairing the open webhook with the unauthenticated flow
  CREATE (the `/configs` bypass) on the vulnerable version, exercising a different
  filter branch and endpoint than the parent repro. The security-relevant
  takeaway for the fix is the untouched `isOpenUrl` branch and the resulting lack
  of trigger-layer defense-in-depth.
- **Scope of proof:** `api_remote` surface and `code_execution` impact are both
  matched on the vulnerable version via the alternate path; on the fixed version the
  chain is blocked at creation (no RCE), consistent with the fix's intent.
