{"repro_id":"REPRO-2026-00247","version":6,"title":"Kestra unauthenticated RCE via AuthenticationFilter path bypass","repro_type":"security","status":"published","severity":"critical","description":"Kestra OSS before 1.0.45 and 1.3.21 has an AuthenticationFilter that uses request.getPath().endsWith('/configs') to whitelist the public configuration endpoint. This check is bypassed by sending a request to an endpoint whose path ends with '/configs', allowing unauthenticated attackers to reach internal APIs and execute arbitrary OS commands via the /api/v1/executions trigger or related flows. Reproduce: build Kestra from the source repository at a vulnerable version, start the web UI/API, then send an unauthenticated HTTP request to a path ending with '/configs' to reach an authenticated endpoint and trigger command execution.","root_cause":"# RCA Report — CVE-2026-49869\n\n## Summary\n\nKestra OSS (before 1.0.45 and 1.3.21) ships an `AuthenticationFilter` that\nwhitelists the public configuration endpoint with a pure path-suffix test:\n\n```java\nboolean isConfigEndpoint = request.getPath().endsWith(\"/configs\") || ...\n```\n\nBecause the check only tests whether the request path **ends with** the literal\n`/configs` segment, *any* `/api/v1/**` request whose final path segment is\n`configs` bypasses Basic Authentication — including requests where `configs` is\nsimply the value of a path variable (a flow namespace or flow id). An\nunauthenticated remote attacker can therefore reach arbitrary authenticated\ncontrollers. By creating a flow whose `namespace` **and** `id` are both the\nliteral `configs`, an attacker makes both the flow-create endpoint\n(`POST /api/v1/main/flows/configs`) and the execution-trigger endpoint\n(`POST /api/v1/main/executions/trigger/configs/configs`) end with `/configs`,\nbypassing the filter end-to-end and achieving unauthenticated remote code\nexecution through a shell task inside the flow.\n\n## Impact\n\n- **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.\n- **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.\n- **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/**`.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Unauthenticated remote code execution (severity: critical).\n- **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.\n- **Parity:** `full`.\n- **Not demonstrated:** N/A — the full claimed impact (unauthenticated RCE via the API) was demonstrated end-to-end on the real product.\n\n## Root Cause\n\n`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:\n\n```java\nboolean isConfigEndpoint = request.getPath().endsWith(\"/configs\")\n    || ((request.getPath().endsWith(\"/basicAuth\") || request.getPath().endsWith(\"/basicAuthValidationErrors\"))\n        && !basicAuthService.isBasicAuthInitialized());\n```\n\nand, if `isConfigEndpoint` (or an open-URL / management endpoint) is true, calls\n`chain.proceed(request)` **without verifying credentials**. The legitimate\npublic endpoint is `GET /api/v1/configs` (MiscController). The intended check\nwas \"is this the configs endpoint\", but `endsWith(\"/configs\")` accepts any path\nthat merely terminates in that segment.\n\nThe Kestra API is tenant-prefixed: `FlowController` is mapped at\n`/api/v1/{tenant}/flows` and `ExecutionController` at `/api/v1/{tenant}/executions`\n(OSS `TenantAliasingRooter` rewrites `/api/v1/<x>` to `/api/v1/main/<x>` when no\nroute matches directly). So a flow whose `namespace=configs` and `id=configs`\nmakes the create and trigger URIs end in `/configs`:\n\n| Action | URI | Last segment | Filter decision |\n|---|---|---|---|\n| Create flow | `POST /api/v1/main/flows/configs` | `configs` | bypass → `chain.proceed` |\n| Trigger flow | `POST /api/v1/main/executions/trigger/configs/configs` | `configs` | bypass → `chain.proceed` |\n\nBoth reach the controller unauthenticated. The flow contains a\n`io.kestra.plugin.scripts.shell.Commands` task (bundled in the official image)\nusing the `io.kestra.plugin.core.runner.Process` task runner, so the attacker's\nshell command executes directly inside the Kestra worker process.\n\n**Fix commit:** `28ff533d8` (\"fix(auth): potential authentication bypass in the\nauthentication filter\", GHSA-5vc5-wxxq-3fjx), released in v1.0.45 / v1.3.21. The\nfix replaces the suffix test with an exact, normalized match:\n\n```java\nString normalizedPath = normalizePath(request.getPath()); // path.replaceAll(\"/+\", \"/\")\nboolean isConfigEndpoint = \"/api/v1/configs\".equals(normalizedPath)\n    || ((normalizedPath.matches(\"/api/v1(/[^/]+)?/basicAuth\")\n        || \"/api/v1/basicAuthValidationErrors\".equals(normalizedPath))\n        && !basicAuthService.isBasicAuthInitialized());\n```\n\nThe `normalizePath` step (collapsing `//`) also closes a related double-slash\nevasion; the added regression tests assert that\n`/api/v1/main/flows/namespace/configs`, `/api/v1/main/namespace/basicAuth`, and\n`/api/v1/main/basicAuthValidationErrors` now return `401`.\n\n## Reproduction Steps\n\n1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained, idempotent).\n2. **What it does:**\n   1. Pulls the official Kestra images `kestra/kestra:v1.0.44` (vulnerable) and `kestra/kestra:v1.0.45` (fixed).\n   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.\n   3. Against the **vulnerable** instance, with **no credentials**, it:\n      - sends a control request to a protected path that does *not* end in `/configs` (expect `401`),\n      - sends the same resource via a `/configs`-suffixed path (expect *not* `401` — bypass),\n      - `POST /api/v1/main/flows/configs` to create an arbitrary flow (`id=configs`, `namespace=configs`) containing a shell task,\n      - `POST /api/v1/main/executions/trigger/configs/configs?wait=true` to trigger it,\n      - reads the marker file the shell command wrote inside the worker container.\n   4. Against the **fixed** instance it repeats the same unauthenticated bypass requests (expect `401` for all) and confirms no marker is produced.\n   5. Writes `bundle/repro/runtime_manifest.json` and `bundle/logs/results_summary.txt`.\n3. **Expected evidence of reproduction:**\n   - 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.\n   - Fixed: bypass=`401`, unauth create=`401`, unauth trigger=`401`, no marker file.\n\n## Evidence\n\n- `bundle/logs/results_summary.txt` — annotated HTTP status codes for every request on both versions and the final verdict flags.\n- `bundle/logs/vuln_create_flow_resp.txt` — `200` response body returning the created flow (revision 1) with the attacker's shell task.\n- `bundle/logs/vuln_trigger_resp.txt` — `200` response body returning the execution whose task run reached `SUCCESS`.\n- `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.\n- `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.\n- `bundle/logs/fixed_marker.txt` — confirms no marker file exists on the fixed version.\n- `bundle/logs/vuln_container.log`, `bundle/logs/fixed_container.log` — server startup logs (844 plugins registered; standalone server running).\n- `bundle/repro/malicious_flow.yaml` — the exact flow source submitted unauthenticated.\n- `bundle/repro/runtime_manifest.json` — structured runtime evidence (`entrypoint_kind=api_remote`, `target_path_reached=true`, `rce_observed=true`, `fixed_blocks_bypass=true`).\n\nKey excerpt (vulnerable, unauthenticated):\n\n```\n[vuln] GET /api/v1/main/flows/configs/configs/revisions (no creds, no /configs suffix) -> 401 (expect 401: auth enforced)\n[vuln] GET /api/v1/main/flows/configs/configs (no creds, /configs suffix) -> 404 (expect NOT 401: bypass)\n[vuln] POST /api/v1/main/flows/configs (no creds, create flow) -> 200 (expect 200)\n[vuln] POST /api/v1/main/executions/trigger/configs/configs?wait=true (no creds, trigger) -> 200 (expect 200)\n[vuln] RCE CONFIRMED: marker file written by attacker command: PWNED_KESTRA_RCE_<token>\n```\n\nKey excerpt (fixed, negative control):\n\n```\n[fixed] GET .../flows/configs/configs (no creds, /configs suffix) -> 401 (expect 401: bypass closed)\n[fixed] POST .../flows/configs (no creds, create flow) -> 401 (expect 401)\n[fixed] POST .../trigger/configs/configs (no creds, trigger) -> 401 (expect 401)\n[fixed] no marker file created (expected)\n```\n\n**Environment:** Official Docker images `kestra/kestra:v1.0.44` and `:v1.0.45`\n(eclipse-temurin 21 JRE, 844 bundled plugins). Standalone topology with\nin-memory H2 queue/repository and local storage. Basic Auth enabled. All HTTP\nrequests were issued from inside the running Kestra container\n(`docker exec <container> curl http://localhost:8080/...`) so the proof is\nindependent of the host/sandbox network topology. The OS command executed as the\n`kestra` service account (uid 1000).\n\n## Recommendations / Next Steps\n\n- **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.\n- **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.\n- **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.\n- **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`).\n- **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.\n\n## Additional Notes\n\n- **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.\n- **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.\n- **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.\n- **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.\n","cve_id":"CVE-2026-49869","source_url":"kestra-io/kestra","reproduced_at":"2026-07-06T08:36:20.898348+00:00","duration_secs":1585.0,"tool_calls":257,"handoffs":2,"total_cost_usd":4.299365550000001,"agent_costs":{"hypothesis_generator":0.0154216,"judge":0.0223805,"repro":2.61261684,"support":0.11027379,"vuln_variant":1.53867282},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0154216},"judge":{"gpt-5.4-mini":0.0223805},"repro":{"accounts/fireworks/routers/glm-5p2-fast":2.61261684},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.11027379},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.53867282}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-06T08:36:47.453246+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":14643,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":12126,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":21147,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":15231,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":11912,"category":"other"},{"path":"bundle/logs/variant_results_summary.txt","filename":"variant_results_summary.txt","size":2152,"category":"other"},{"path":"bundle/logs/variant_vuln_marker.txt","filename":"variant_vuln_marker.txt","size":110,"category":"other"},{"path":"bundle/logs/variant_vuln_trigger_resp.txt","filename":"variant_vuln_trigger_resp.txt","size":709,"category":"other"},{"path":"bundle/logs/variant_fixed_webhook_resp.txt","filename":"variant_fixed_webhook_resp.txt","size":283,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1916,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":3267,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1220,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1188,"category":"other"},{"path":"bundle/logs/results_summary.txt","filename":"results_summary.txt","size":1071,"category":"other"},{"path":"bundle/logs/vuln_marker.txt","filename":"vuln_marker.txt","size":103,"category":"other"},{"path":"bundle/logs/vuln_create_flow_resp.txt","filename":"vuln_create_flow_resp.txt","size":580,"category":"other"},{"path":"bundle/logs/vuln_trigger_resp.txt","filename":"vuln_trigger_resp.txt","size":1589,"category":"other"},{"path":"bundle/repro/malicious_flow.yaml","filename":"malicious_flow.yaml","size":257,"category":"other"},{"path":"bundle/logs/vuln_container.log","filename":"vuln_container.log","size":10219,"category":"log"},{"path":"bundle/logs/fixed_bypass_resp.txt","filename":"fixed_bypass_resp.txt","size":0,"category":"other"},{"path":"bundle/logs/fixed_create_flow_resp.txt","filename":"fixed_create_flow_resp.txt","size":0,"category":"other"},{"path":"bundle/logs/fixed_trigger_resp.txt","filename":"fixed_trigger_resp.txt","size":0,"category":"other"},{"path":"bundle/logs/fixed_marker.txt","filename":"fixed_marker.txt","size":74,"category":"other"},{"path":"bundle/logs/fixed_container.log","filename":"fixed_container.log","size":10218,"category":"log"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":4260,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4814,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":9291,"category":"documentation"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1618,"category":"other"}]}