Summary
The Kestra controller opens a second network server, a gRPC/HTTP2 control plane that listens on port 50051 by default, and this server accepts requests with no authentication whatsoever. In the open source distribution the server is built with plaintext (insecure) transport credentials and installs a single interceptor that performs no credential, token, or client-certificate verification. Because the standalone and webserver run modes start this controller automatically and bind it to all interfaces (0.0.0.0), any peer that can reach port 50051 can read, enumerate, write, and soft-delete key-value and namespace-file metadata for any tenant and namespace it names, retrieve worker configuration, register as a worker to receive dispatched job payloads, and push forged task and trigger results into the execution pipeline. No credential is required and a single deterministic request is sufficient.
Root Cause
The controller's gRPC server is created with insecure transport credentials and the only server interceptor it installs does not authenticate the caller. The interceptor exists solely to mark every incoming call as an internal (worker-originated) call; it never inspects request metadata, and there is no other interceptor on the server. The HTTP-layer authentication filters that protect the Micronaut web server (basic auth, management-endpoint auth, CSRF) are bound to the HTTP servers and never apply to this separate gRPC server, which is built on raw grpc-java rather than the framework's gRPC integration.
The service methods then act directly on attacker-supplied identifiers. Each RPC reads the tenant, namespace, and name straight off the request and passes them into the metadata state store, which delegates to the JDBC repository. The only row-level access control in the open source build is a namespace ACL that is a deliberate no-op there: the default access-control bean grants global scope for every resource, so the ACL condition collapses to an unconditional match and only the attacker-controlled tenant filter remains. The result is an unauthenticated, unauthorized data and control-plane surface.
Affected components (repository-relative source):
- worker-controller/src/main/java/io/kestra/controller/DefaultController.java
createServerCredentials() returns InsecureServerCredentials.create(); buildServer(int)
installs only new InternalCallServerInterceptor() on the gRPC server.
- worker-controller/src/main/java/io/kestra/controller/grpc/InternalCallServerInterceptor.java
interceptCall(...) reads no Metadata and rejects nothing.
- worker-controller/src/main/java/io/kestra/controller/grpc/services/GrpcKVMetadataControllerService.java
find/findByName/existsByNamespace/save/deleteByName act on request tenant/namespace/name.
- worker-controller/src/main/java/io/kestra/controller/grpc/services/GrpcConnectControllerService.java
resolveWorkerGroupId(...) unconditionally returns the default worker group; connect(...) returns worker configs.
- core/src/main/java/io/kestra/core/repositories/GlobalNamespaceAccessControl.java
namespaceScope(...) returns AccessScope.global(), making the ACL condition a no-op.
Impact
An attacker who can open a TCP connection to port 50051 (no TLS, no credentials) gains the full control-plane RPC surface. Demonstrated capabilities, all traced through source:
- Confidentiality: read and enumerate key-value metadata and namespace-file metadata for any tenant and namespace; obtain worker configuration through the connect RPC. Worker configuration and dispatched job payloads routinely carry rendered task properties, which is the same secret-bearing data class already flagged for the analogous unauthenticated HTTP management surface.
- Integrity: write (save) and soft-delete key-value and namespace-file metadata for any namespace; register as a worker over the job stream and push forged task-result and trigger-result messages into the executor queues, corrupting execution and trigger state.
- Availability: metadata deletion and worker-registration or job-starvation degrade service without necessarily causing a full outage.
Prerequisites: network reachability to port 50051. In the Helm chart the port is exposed on all pods and on the standalone Service by default (ClusterIP), so any in-cluster peer reaches it when no NetworkPolicy is present. A bare standalone process on a routable, un-firewalled host exposes it on 0.0.0.0 to any network peer. The documented docker-compose quickstart does not publish the port off-host. The Enterprise distribution overrides the transport credentials (TLS) and the worker-group resolution (authenticated worker context), so those builds reject the anonymous connection; the open source build is the affected configuration.
Proof of Concept
Setup:
- A Kestra 2.x open source instance running in standalone mode (default), reachable on port 50051.
- The grpcurl tool and the service protobuf definitions shipped in worker-controller/src/main/proto/.
- No credentials of any kind.
- Point grpcurl at the target's gRPC control plane on port 50051 using plaintext.
- Call an unauthenticated read, for example KVMetadataService/existsByNamespace or KVMetadataService/find, supplying an arbitrary tenantId and namespace.
- Observe the response returning metadata for the named namespace with no authentication challenge.
- As a negative control, issue any authenticated HTTP API call to the web server without credentials and observe a 401, confirming the HTTP auth layer does not extend to the gRPC plane.
# Unauthenticated read against the gRPC control plane.
grpcurl -plaintext \
-import-path worker-controller/src/main/proto -proto kv_metadata.proto \
-d '{"tenantId":"main","namespace":"company.team"}' \
TARGET_HOST:50051 kestra.controller.grpc.KVMetadataService/find
# Negative control: HTTP API rejects the same anonymous caller.
curl -s -o /dev/null -w '%{http_code}\n' http://TARGET_HOST:8080/api/v1/flows/company.team
The gRPC call returns the namespace key-value metadata payload with no auth error,
while the HTTP control returns 401, demonstrating the gRPC plane is unauthenticated
while the HTTP plane is not.
Note: this proof of concept is a source-backed encoding of the reachable RPC surface; it was not executed against a live cluster during evaluation because none was available. The end-to-end chain is established by a complete source trace of the released artifact.
Attack Chain
- Exposure: The controller starts by default in standalone and webserver run modes and binds the gRPC server to 0.0.0.0:50051. The Helm chart exposes the port on all pods and the standalone Service by default, and a standalone process exposes it on all host interfaces.
- Control: The attacker fully controls the RPC request fields, including tenantId, namespace, name, and the serialized metadata objects for write operations.
- Path: The connection terminates on the raw grpc-java server built by DefaultController.buildServer; the sole InternalCallServerInterceptor wraps the call as an internal call and forwards it to the service bean, which reads the request identifiers and calls the metadata state store, which delegates to the JDBC repository.
- Guard: The transport uses InsecureServerCredentials (no TLS, no client certificate), the interceptor performs no authentication, and the open source namespace ACL resolves to global scope so the repository's ACL condition is a no-op; only the attacker-supplied tenant filter remains.
- Primitive: Unauthenticated read, enumerate, write, and soft-delete of key-value and namespace-file metadata; retrieval of worker configuration; worker registration and submission of forged task and trigger results.
- Result: An unauthenticated network peer reads secret-bearing configuration and metadata, mutates stored metadata, and corrupts execution and trigger state within the deployment.
Bypass Evidence
The controller's gRPC server has no authenticating guard to bypass; this section records the checks performed to confirm that, and the attempts to disprove the finding.
- The only interceptor installed on the server is InternalCallServerInterceptor. A search for interceptor registration in the worker-controller module returns exactly one intercept registration. Reading the interceptor in full confirms it never reads request metadata and rejects nothing on any of its listener entry points.
- The server is constructed with raw grpc-java (a direct server builder for the port), not the framework's gRPC integration, so the HTTP-layer security filters (basic auth, management-endpoint auth, CSRF) do not apply to this socket.
- Attempt to find a working ACL: the open source access-control bean returns global scope for every resource, and the repository ACL condition maps global scope to an unconditional match. The row filter therefore evaporates and only the attacker-supplied tenant condition applies.
- Attempt to rely on the internal-call marker as a guard: the internal-call flag has no production consumer in the open source tree (it is only set by the interceptor and read by tests), so it cannot function as an authorization gate there. The original "internal-call permission bypass" framing was corrected accordingly; the privileged effect is proven directly through the service-to-repository trace and does not depend on that marker.
- Attempt to find a worker-identity check on connect: the worker-group resolver unconditionally returns the default worker group in the open source build, so worker registration and configuration retrieval require no identity.
- Independence from existing advisories: the previously disclosed unauthenticated management issues live on the HTTP management server and are remediated by an HTTP filter, which is architecturally incapable of protecting the separate gRPC server; the previously disclosed namespace prefix-collision issue concerns the Enterprise ACL logic, whereas the open source ACL is already a no-op. None of them cover this transport or remediation boundary.
Affected Versions
Ecosystem: other (self-hosted Kestra open source, tracked by GitHub release tag)
Package: kestra
Confirmed affected range: >= 2.0.0
Latest release checked: v2.0.2 (GitHub releases, kestra-io/kestra, published 2026-09-15)
Fix status: not fixed
The gRPC worker-controller control plane is a Kestra 2.0 feature, so the issue is introduced at 2.0.0. The vulnerable code (insecure transport credentials, the sole non-authenticating interceptor, and the no-op open source ACL) is present in the latest released artifact v2.0.2, which was inspected directly, and no fix exists in the current history. The range applies to open source editions only; the Enterprise distribution overrides the transport credentials and worker-group resolution and is not affected in the same way. The range is unbounded above because no fixed release exists.
Suggested Fix
Enforce authentication and transport security on the gRPC control plane in the open source build, at the point where the server is created. The controller should require an authenticated caller before any service method runs, for example by installing an authenticating server interceptor that verifies a credential or a client certificate and rejecting unauthenticated calls, and by defaulting to mutual TLS rather than plaintext insecure credentials. This mirrors the transport security and worker-identity resolution that the Enterprise distribution already applies, brought down to the open source controller. The remediation must cover every service registered on the server (key-value metadata, namespace-file metadata, connect, worker job stream, and logs), not only the read paths, and must not rely on the internal-call marker, which is not an authorization decision.
Interim mitigation without a code change: ensure port 50051 is never reachable by untrusted peers. Apply a Kubernetes NetworkPolicy restricting access to the controller Service to worker pods only, and firewall the port on standalone hosts so that only trusted workers can connect.
Reported by zx (GitHub: @manus-pi).
Summary
The Kestra controller opens a second network server, a gRPC/HTTP2 control plane that listens on port 50051 by default, and this server accepts requests with no authentication whatsoever. In the open source distribution the server is built with plaintext (insecure) transport credentials and installs a single interceptor that performs no credential, token, or client-certificate verification. Because the standalone and webserver run modes start this controller automatically and bind it to all interfaces (0.0.0.0), any peer that can reach port 50051 can read, enumerate, write, and soft-delete key-value and namespace-file metadata for any tenant and namespace it names, retrieve worker configuration, register as a worker to receive dispatched job payloads, and push forged task and trigger results into the execution pipeline. No credential is required and a single deterministic request is sufficient.
Root Cause
The controller's gRPC server is created with insecure transport credentials and the only server interceptor it installs does not authenticate the caller. The interceptor exists solely to mark every incoming call as an internal (worker-originated) call; it never inspects request metadata, and there is no other interceptor on the server. The HTTP-layer authentication filters that protect the Micronaut web server (basic auth, management-endpoint auth, CSRF) are bound to the HTTP servers and never apply to this separate gRPC server, which is built on raw grpc-java rather than the framework's gRPC integration.
The service methods then act directly on attacker-supplied identifiers. Each RPC reads the tenant, namespace, and name straight off the request and passes them into the metadata state store, which delegates to the JDBC repository. The only row-level access control in the open source build is a namespace ACL that is a deliberate no-op there: the default access-control bean grants global scope for every resource, so the ACL condition collapses to an unconditional match and only the attacker-controlled tenant filter remains. The result is an unauthenticated, unauthorized data and control-plane surface.
Impact
An attacker who can open a TCP connection to port 50051 (no TLS, no credentials) gains the full control-plane RPC surface. Demonstrated capabilities, all traced through source:
Prerequisites: network reachability to port 50051. In the Helm chart the port is exposed on all pods and on the standalone Service by default (ClusterIP), so any in-cluster peer reaches it when no NetworkPolicy is present. A bare standalone process on a routable, un-firewalled host exposes it on 0.0.0.0 to any network peer. The documented docker-compose quickstart does not publish the port off-host. The Enterprise distribution overrides the transport credentials (TLS) and the worker-group resolution (authenticated worker context), so those builds reject the anonymous connection; the open source build is the affected configuration.
Proof of Concept
Note: this proof of concept is a source-backed encoding of the reachable RPC surface; it was not executed against a live cluster during evaluation because none was available. The end-to-end chain is established by a complete source trace of the released artifact.
Attack Chain
Bypass Evidence
The controller's gRPC server has no authenticating guard to bypass; this section records the checks performed to confirm that, and the attempts to disprove the finding.
Affected Versions
Ecosystem: other (self-hosted Kestra open source, tracked by GitHub release tag)Package: kestraConfirmed affected range: >= 2.0.0Latest release checked: v2.0.2 (GitHub releases, kestra-io/kestra, published 2026-09-15)Fix status: not fixedThe gRPC worker-controller control plane is a Kestra 2.0 feature, so the issue is introduced at 2.0.0. The vulnerable code (insecure transport credentials, the sole non-authenticating interceptor, and the no-op open source ACL) is present in the latest released artifact v2.0.2, which was inspected directly, and no fix exists in the current history. The range applies to open source editions only; the Enterprise distribution overrides the transport credentials and worker-group resolution and is not affected in the same way. The range is unbounded above because no fixed release exists.
Suggested Fix
Enforce authentication and transport security on the gRPC control plane in the open source build, at the point where the server is created. The controller should require an authenticated caller before any service method runs, for example by installing an authenticating server interceptor that verifies a credential or a client certificate and rejecting unauthenticated calls, and by defaulting to mutual TLS rather than plaintext insecure credentials. This mirrors the transport security and worker-identity resolution that the Enterprise distribution already applies, brought down to the open source controller. The remediation must cover every service registered on the server (key-value metadata, namespace-file metadata, connect, worker job stream, and logs), not only the read paths, and must not rely on the internal-call marker, which is not an authorization decision.
Interim mitigation without a code change: ensure port 50051 is never reachable by untrusted peers. Apply a Kubernetes NetworkPolicy restricting access to the controller Service to worker pods only, and firewall the port on standalone hosts so that only trusted workers can connect.
Reported by zx (GitHub: @manus-pi).