# Patch Analysis — CVE-2026-49869 (Kestra AuthenticationFilter path bypass)

## What the fix changes

Fix commit: `28ff533d85e6c977e209f90e87e8523d0f8888eb` — *"fix(auth): potential
authentication bypass in the authentication filter"* (Loïc Mathieu, 2026-05-27),
released in **v1.0.45** and **v1.3.21** (GHSA-5vc5-wxxq-3fjx). The fix touches
exactly one production file and one test file:

- `webserver/src/main/java/io/kestra/webserver/filter/AuthenticationFilter.java`
- `webserver/src/test/java/io/kestra/webserver/filter/AuthenticationFilterTest.java`

The vulnerable code in `AuthenticationFilter.doFilter` was:

```java
boolean isConfigEndpoint = request.getPath().endsWith("/configs")
    || ((request.getPath().endsWith("/basicAuth") || request.getPath().endsWith("/basicAuthValidationErrors"))
        && !basicAuthService.isBasicAuthInitialized());
```

The fixed code is:

```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());
```

with the new helper:

```java
private static String normalizePath(String path) {
    return path.replaceAll("/+", "/");
}
```

### Concrete changes

1. **`/configs` whitelist**: pure `endsWith("/configs")` → exact equality
   `"/api/v1/configs".equals(normalizedPath)`. Any `/api/v1/**` path whose last
   segment was `configs` (e.g. a flow namespace or id equal to `configs`) is no
   longer whitelisted. Only the real MiscController `@Get("/configs")` endpoint is.
2. **`/basicAuth` whitelist**: `endsWith("/basicAuth")` → regex
   `/api/v1(/[^/]+)?/basicAuth` (zero or one tenant segment), still gated by
   `!isBasicAuthInitialized()`.
3. **`/basicAuthValidationErrors` whitelist**: `endsWith(...)` → exact equality
   `"/api/v1/basicAuthValidationErrors".equals(normalizedPath)`, still gated by
   `!isBasicAuthInitialized()`.
4. **`normalizePath`**: collapses runs of slashes (`//` → `/`) before the
   equality/regex checks, closing a related double-slash evasion.
5. **Regression tests** added:
   `configEndpointShouldBeCheckedExactly` (GET `/api/v1/main/flows/namespace/configs` → 401),
   `basicAuthEndpointShouldBeCheckedExactly` (POST `/api/v1/main/namespace/basicAuth` → 401, two-segment path),
   `basicAuthValidationErrorsEndpointShouldBeCheckedExactly` (GET `/api/v1/main/basicAuthValidationErrors` → 401).

## What the fix assumes

- **Assumption A (single open branch)**: the fix author treated `isConfigEndpoint`
  as the only branch that needed hardening. The other two open-path branches in the
  SAME filter — `isOpenUrl` and `isManagementEndpoint` — were NOT modified.
- **Assumption B (filter/router path consistency)**: `request.getPath()` (used by
  the filter) and the router's path matching (TenantAliasingRooter uses
  `request.getUri().getPath()`) view the path consistently after
  slash-normalization, so an exact-equality whitelist cannot diverge from routing.
- **Assumption C (basicAuth branch setup-only)**: the `/basicAuth` and
  `/basicAuthValidationErrors` whitelists are acceptable because they are gated by
  `!isBasicAuthInitialized()` (only open during initial setup), and the regex
  `/api/v1(/[^/]+)?/basicAuth` matches only the real MiscController
  `@Post("/{tenant}/basicAuth")` setup route.
- **Assumption D (creation is the gate)**: once flow CREATION requires
  authentication (post-fix), an unauthenticated attacker cannot plant a malicious
  flow, so unauthenticated RCE via flow execution is prevented.

## What the fix does NOT cover

### 1. The `isOpenUrl` branch is untouched (the variant finding)

```java
boolean isOpenUrl = Optional.ofNullable(basicAuthConfiguration.getOpenUrls())
    .map(Collection::stream)
    .map(stream -> stream.anyMatch(s -> request.getPath().startsWith(s)))
    .orElse(false);
```

This branch uses **`startsWith`** (a prefix match, the same class of over-permissive
string-match as the original `endsWith` suffix bug) and is **NOT modified by the
fix**. The production-default `kestra.server.basic-auth.open-urls`
(`cli/src/main/resources/application.yml`) is:

```yaml
open-urls:
  - "/ping"
  - "/api/v1/executions/webhook/"
  - "/api/v1/main/executions/webhook/"
  - "/api/v1/*/executions/webhook/"
  - "/api/v1/basicAuthValidationErrors"
```

Therefore `POST/GET/PUT /api/v1/{tenant}/executions/webhook/{namespace}/{id}/{key}`
(ExecutionController `triggerExecutionBy{Post,Get,Put}Webhook`) is reachable
**unauthenticated on every version, including the fixed v1.0.45 and the latest
v1.0.49** (verified: `isOpenUrl` + default open-urls are byte-identical in v1.0.49).
This is **documented behavior** — webhook triggers are public triggers secured only
by the `key` (the Webhook trigger schema explicitly states the key is the only
security mechanism) — so it is not itself a vulnerability. The security-relevant
consequence, however, is that **the fix's protection against unauthenticated RCE
relies ENTIRELY on Assumption D (blocking flow creation)**. The trigger layer
remains a wide-open, unauthenticated execution surface that the fix did not touch.

### 2. The `isManagementEndpoint` branch is untouched

Routes whose matched `MethodBasedRouteMatch` carries `@Endpoint` (Micronaut
management endpoints: `VersionEndpoint`, `WorkerEndpoint`, `SchedulerEndpoint`,
plus built-ins on port 8081) bypass auth. These are by-design open and are not
write/code-execution surfaces; out of scope for this CVE.

### 3. basicAuth regex residual breadth (setup window only)

The regex `/api/v1(/[^/]+)?/basicAuth` matches any ONE-segment path
`/api/v1/<X>/basicAuth`, not only the tenant `main`. Empirically (setup-state
test): `GET /api/v1/flows/basicAuth` is whitelisted during setup and, after
TenantAliasingRooter rewrites it to `/api/v1/main/flows/basicAuth`, reaches
FlowController's `@Get("/{namespace}")` (read flows in namespace `basicAuth`,
returned 200). `POST /api/v1/flows/basicAuth` routes to MiscController's
`@Post("/{tenant}/basicAuth")` createBasicAuth setup endpoint (returned 422 for a
non-credentials body) — i.e. the intended setup endpoint, NOT a flow-create bypass.
Two-segment paths (`/api/v1/main/namespace/basicAuth`) correctly return 401 (the
fix's own test case). Net: the residual breadth yields only a negligible
read-of-namespace-`basicAuth` during the setup window; it is **not** a
normal-operation bypass and **not** an RCE path.

## Behavior before vs after the fix

| Request (no creds) | v1.0.44 (vuln) | v1.0.45 (fixed) |
|---|---|---|
| `POST /api/v1/main/flows/configs` (create flow, path ends /configs) | **200** (bypass → RCE chain) | **401** (closed) |
| `POST /api/v1/main/executions/trigger/configs/configs?wait=true` | **200** (bypass → RCE) | **401** (closed) |
| `GET /api/v1/main/executions/webhook/configs/configs/<key>` (open-url) | **200** (open, by design) | **404** "Flow not found" (still open, NOT 401) |
| `GET /api/v1//configs` (double slash) | bypassed (endsWith /configs) | **404** (whitelisted by normalize but unroutable) |
| `GET /api/v1/configs/` (trailing slash) | bypassed | **401** (exact match fails) |
| `POST /api/v1/main/flows%2Fconfigs` (encoded slash) | bypassed | **401** |
| `GET /api/v1/main/flows/namespace/configs` (test case) | bypassed | **401** (fix test) |

## Is the fix complete?

**For the disclosed CVE (unauthenticated RCE via the `/configs` suffix in normal
operation): YES, complete.** The exact-match + `normalizePath` closes the
`/configs` suffix bypass, and the basicAuth regex + setup gate closes the parallel
`/basicAuth`/`/basicAuthValidationErrors` suffix bypasses. No true bypass (RCE on
the fixed version in normal operation) was found.

**For defense-in-depth at the trigger layer: INCOMPLETE.** The `isOpenUrl` branch
(`startsWith`, default open-urls including `/api/v1/executions/webhook/`) is the
same over-permissive string-match class of bug as the original `endsWith`, and the
fix did not address it. The open webhook execution endpoint remains an
unauthenticated flow-execution trigger on the fixed and latest versions. This is
documented behavior, but it means the fix's security guarantee rests solely on
blocking flow creation (Assumption D); if flow creation is ever re-bypassed, the
open webhook provides an immediate unauthenticated RCE trigger that the fix does
not protect against.

## Target threat-model scope

`SECURITY.md` defines a standard private-reporting policy and supported versions
(latest + two prior minors). It does NOT carve out the AuthenticationFilter or
webhook endpoints as out-of-scope. The Webhook trigger's own schema
(`core/.../plugin/core/trigger/Webhook.java`) explicitly documents that the `key`
"is the only security mechanism to protect your endpoint from bad actors, and must
be considered as a secret" — i.e. unauthenticated webhook triggering is an
accepted, documented trust boundary. The variant is reported within that scope: it
does not claim the open webhook is a flaw, but highlights that the fix did not
harden the `isOpenUrl` branch and that flow creation is the sole remaining gate.
