Skip to content

Unauthenticated gRPC control plane on port 50051 exposes and mutates KV/namespace metadata and worker jobs

High
loicmathieu published GHSA-hrr4-xg8h-5p6f Sep 29, 2026

Software

kestra

Affected versions

>= 2.0.0

Patched versions

2.0.3

Description

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.
  1. Point grpcurl at the target's gRPC control plane on port 50051 using plaintext.
  2. Call an unauthenticated read, for example KVMetadataService/existsByNamespace or KVMetadataService/find, supplying an arbitrary tenantId and namespace.
  3. Observe the response returning metadata for the named namespace with no authentication challenge.
  4. 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

  1. 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.
  2. Control: The attacker fully controls the RPC request fields, including tenantId, namespace, name, and the serialized metadata objects for write operations.
  3. 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.
  4. 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.
  5. 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.
  6. 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).

Severity

High

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
Adjacent
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
Low

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:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L

CVE ID

No known CVE

Weaknesses

Missing Authentication for Critical Function

The product does not perform any authentication for functionality that requires a provable user identity or consumes a significant amount of resources. Learn more on MITRE.

Credits