CVE-2026-49821
Description
Fission is an open-source, Kubernetes-native serverless framework that simplifies the deployment of functions and applications on Kubernetes. Prior to version 1.24.0, Fission's buildermgr controller processed Package CRDs without verifying that Package.spec.environment.namespace matched Package.metadata.namespace. This issue has been patched in version 1.24.0.
Affected products
2Patches
1e2b92663499fReject cross-namespace references in Package + KubernetesWatchTrigger (GHSA-vjhc, GHSA-gc3j) (#3379)
8 files changed · +331 −5
pkg/buildermgr/common.go+9 −0 modified@@ -47,6 +47,15 @@ import ( func buildPackage(ctx context.Context, logger logr.Logger, fissionClient versioned.Interface, envBuilderNamespace string, storageSvcUrl string, pkg *fv1.Package) (uploadResp *fetcher.ArchiveUploadResponse, buildLogs string, err error) { + // Defence in depth against cross-namespace Environment references; the + // admission webhook is the user-visible reject, this guard catches + // objects that bypassed it (GHSA-vjhc-cf4p-72q4). + if pkg.Spec.Environment.Namespace != "" && pkg.Spec.Environment.Namespace != pkg.Namespace { + e := fmt.Sprintf("cross-namespace environment reference is not allowed: pkg.namespace=%s env.namespace=%s", + pkg.Namespace, pkg.Spec.Environment.Namespace) + return nil, e, ferror.MakeError(ferror.ErrorInvalidArgument, e) + } + env, err := fissionClient.CoreV1().Environments(pkg.Spec.Environment.Namespace).Get(ctx, pkg.Spec.Environment.Name, metav1.GetOptions{}) if err != nil { e := "error getting environment CRD info"
pkg/buildermgr/pkgwatcher.go+14 −0 modified@@ -107,6 +107,20 @@ func (pkgw *packageWatcher) build(ctx context.Context, srcpkg *fv1.Package) { return } + // Defence in depth — the admission webhook should already have rejected + // a cross-namespace environment reference at submit time, but reconcile + // loops can still see stale objects on upgraded clusters or on clusters + // running the webhook with failurePolicy=Ignore (GHSA-vjhc-cf4p-72q4). + if pkg.Spec.Environment.Namespace != "" && pkg.Spec.Environment.Namespace != pkg.Namespace { + msg := fmt.Sprintf("cross-namespace environment reference is not allowed: pkg.namespace=%s env.namespace=%s", + pkg.Namespace, pkg.Spec.Environment.Namespace) + logger.Info("rejecting cross-namespace environment reference", "env_namespace", pkg.Spec.Environment.Namespace) + if _, er := updatePackage(ctx, logger, pkgw.fissionClient, pkg, fv1.BuildStatusFailed, msg, nil); er != nil { + logger.Error(er, "error updating package") + } + return + } + env, err := pkgw.fissionClient.CoreV1().Environments(pkg.Spec.Environment.Namespace).Get(ctx, pkg.Spec.Environment.Name, metav1.GetOptions{}) if k8serrors.IsNotFound(err) { e := "environment does not exist"
pkg/kubewatcher/kubewatcher.go+22 −4 modified@@ -102,6 +102,24 @@ func createKubernetesWatch(ctx context.Context, kubeClient kubernetes.Interface, var err error var watchTimeoutSec int64 = 120 + // Refuse cross-namespace targets — the webhook should already reject + // these at admission, but reconcile loops can see stale objects on + // upgraded clusters or on webhook-failurePolicy=Ignore deployments + // (GHSA-gc3j-79f2-7vvw). + if w.Spec.Namespace != "" && w.Spec.Namespace != w.Namespace { + return nil, fmt.Errorf("cross-namespace watch is not allowed: trigger.namespace=%s spec.namespace=%s", + w.Namespace, w.Spec.Namespace) + } + + // An empty Spec.Namespace previously meant "watch all namespaces" via + // client-go's empty-namespace semantics — a separate cross-tenant leak. + // Coerce it to the trigger's own namespace so an unset field can never + // resolve to cluster-wide visibility. + target := w.Spec.Namespace + if target == "" { + target = w.Namespace + } + // TODO populate labelselector and fieldselector listOptions := metav1.ListOptions{ ResourceVersion: resourceVersion, @@ -111,13 +129,13 @@ func createKubernetesWatch(ctx context.Context, kubeClient kubernetes.Interface, // TODO handle the full list of types switch strings.ToUpper(w.Spec.Type) { case "POD": - wi, err = kubeClient.CoreV1().Pods(w.Spec.Namespace).Watch(ctx, listOptions) + wi, err = kubeClient.CoreV1().Pods(target).Watch(ctx, listOptions) case "SERVICE": - wi, err = kubeClient.CoreV1().Services(w.Spec.Namespace).Watch(ctx, listOptions) + wi, err = kubeClient.CoreV1().Services(target).Watch(ctx, listOptions) case "REPLICATIONCONTROLLER": - wi, err = kubeClient.CoreV1().ReplicationControllers(w.Spec.Namespace).Watch(ctx, listOptions) + wi, err = kubeClient.CoreV1().ReplicationControllers(target).Watch(ctx, listOptions) case "JOB": - wi, err = kubeClient.BatchV1().Jobs(w.Spec.Namespace).Watch(ctx, listOptions) + wi, err = kubeClient.BatchV1().Jobs(target).Watch(ctx, listOptions) default: err = errors.NewBadRequest(fmt.Sprintf("Error: unknown obj type '%v'", w.Spec.Type)) }
pkg/kubewatcher/kubewatcher_test.go+107 −0 added@@ -0,0 +1,107 @@ +/* +Copyright 2026 The Fission Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kubewatcher + +import ( + "context" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" + + fv1 "github.com/fission/fission/pkg/apis/core/v1" +) + +// captureWatchedNamespace records the namespace passed to the Watch verb on +// the fake client. createKubernetesWatch hits client-go's Pods(ns).Watch path; +// the fake client exposes the request via WatchAction.Namespace. +func captureWatchedNamespace(t *testing.T, w *fv1.KubernetesWatchTrigger) (string, error) { + t.Helper() + kc := fake.NewSimpleClientset() + var ns string + kc.PrependWatchReactor("pods", func(action clienttesting.Action) (bool, watch.Interface, error) { + ns = action.GetNamespace() + return true, watch.NewFake(), nil + }) + _, err := createKubernetesWatch(context.Background(), kc, w, "") + return ns, err +} + +func TestCreateKubernetesWatch_RejectsCrossNamespace(t *testing.T) { + w := &fv1.KubernetesWatchTrigger{ + ObjectMeta: metav1.ObjectMeta{Name: "kwt-1", Namespace: "ns-attacker"}, + Spec: fv1.KubernetesWatchTriggerSpec{ + Namespace: "ns-victim", + Type: "POD", + }, + } + kc := fake.NewSimpleClientset() + called := false + kc.PrependWatchReactor("pods", func(action clienttesting.Action) (bool, watch.Interface, error) { + called = true + return true, watch.NewFake(), nil + }) + _, err := createKubernetesWatch(context.Background(), kc, w, "") + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), "cross-namespace watch is not allowed") { + t.Fatalf("expected cross-namespace error, got: %v", err) + } + if called { + t.Fatalf("Watch reactor must not have been invoked when cross-namespace is rejected") + } +} + +func TestCreateKubernetesWatch_EmptySpecNamespaceCoercedToTriggerNamespace(t *testing.T) { + w := &fv1.KubernetesWatchTrigger{ + ObjectMeta: metav1.ObjectMeta{Name: "kwt-1", Namespace: "ns-attacker"}, + Spec: fv1.KubernetesWatchTriggerSpec{ + Namespace: "", + Type: "POD", + }, + } + ns, err := captureWatchedNamespace(t, w) + if err != nil { + t.Fatalf("expected acceptance, got: %v", err) + } + // Pre-fix behaviour would have used "" → all namespaces. Post-fix must + // resolve to the trigger's own namespace. + if ns != "ns-attacker" { + t.Fatalf("expected coerced namespace %q, got %q", "ns-attacker", ns) + } +} + +func TestCreateKubernetesWatch_SameNamespaceAccepted(t *testing.T) { + w := &fv1.KubernetesWatchTrigger{ + ObjectMeta: metav1.ObjectMeta{Name: "kwt-1", Namespace: "default"}, + Spec: fv1.KubernetesWatchTriggerSpec{ + Namespace: "default", + Type: "POD", + }, + } + ns, err := captureWatchedNamespace(t, w) + if err != nil { + t.Fatalf("expected acceptance, got: %v", err) + } + if ns != "default" { + t.Fatalf("expected namespace %q, got %q", "default", ns) + } +}
pkg/webhook/kuberneteswatchtrigger.go+14 −1 modified@@ -17,10 +17,13 @@ limitations under the License. package webhook import ( + "fmt" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/webhook" v1 "github.com/fission/fission/pkg/apis/core/v1" + ferror "github.com/fission/fission/pkg/error" "github.com/fission/fission/pkg/utils/loggerfactory" ) @@ -42,13 +45,23 @@ func (r *KubernetesWatchTrigger) SetupWebhookWithManager(mgr ctrl.Manager) error var _ webhook.CustomDefaulter = &KubernetesWatchTrigger{} // user: change verbs to "verbs=create;update;delete" if you want to enable deletion validation. -//+kubebuilder:webhook:path=/validate-fission-io-v1-kuberneteswatchtrigger,mutating=false,failurePolicy=fail,sideEffects=None,groups=fission.io,resources=kuberneteswatchtriggers,verbs=create,versions=v1,name=vkuberneteswatchtrigger.fission.io,admissionReviewVersions=v1 +//+kubebuilder:webhook:path=/validate-fission-io-v1-kuberneteswatchtrigger,mutating=false,failurePolicy=fail,sideEffects=None,groups=fission.io,resources=kuberneteswatchtriggers,verbs=create;update,versions=v1,name=vkuberneteswatchtrigger.fission.io,admissionReviewVersions=v1 var _ webhook.CustomValidator = &KubernetesWatchTrigger{} func (r *KubernetesWatchTrigger) Validate(new *v1.KubernetesWatchTrigger) error { if err := new.Validate(); err != nil { return v1.AggregateValidationErrors("Watch", err) } + + // Refuse cross-namespace Watch targets — the kubewatcher controller has + // cluster-wide watch privileges and would otherwise stream every event + // from the referenced namespace to the trigger's function, defeating + // namespace-as-tenant boundaries (GHSA-gc3j-79f2-7vvw). + if new.Spec.Namespace != "" && new.Spec.Namespace != new.Namespace { + return ferror.MakeError(ferror.ErrorInvalidArgument, + fmt.Sprintf("KubernetesWatchTrigger.spec.namespace must equal the trigger namespace (got spec.namespace=%q, metadata.namespace=%q)", + new.Spec.Namespace, new.Namespace)) + } return nil }
pkg/webhook/kuberneteswatchtrigger_test.go+79 −0 added@@ -0,0 +1,79 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package webhook + +import ( + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1 "github.com/fission/fission/pkg/apis/core/v1" +) + +func makeValidKWT(triggerNs, specNs string) *v1.KubernetesWatchTrigger { + return &v1.KubernetesWatchTrigger{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kwt-1", + Namespace: triggerNs, + }, + Spec: v1.KubernetesWatchTriggerSpec{ + Namespace: specNs, + Type: "POD", + FunctionReference: v1.FunctionReference{ + Type: v1.FunctionReferenceTypeFunctionName, + Name: "fn-1", + }, + }, + } +} + +func TestKubernetesWatchTriggerWebhook_Validate_CrossNamespace(t *testing.T) { + cases := []struct { + name string + triggerNs string + specNs string + wantRejected bool + wantCrossErr bool // expect the new cross-namespace error specifically + }{ + // Empty Spec.Namespace is already rejected by upstream KubernetesWatchTriggerSpec.Validate + // (ValidateKubeName requires a non-empty RFC 1123 label). Asserted here so we notice + // if upstream validation ever loosens — the controller-side coercion in + // createKubernetesWatch is the safety net for any object that slips through. + {name: "empty spec.namespace rejected by upstream Validate", triggerNs: "default", specNs: "", wantRejected: true, wantCrossErr: false}, + {name: "same namespace is accepted", triggerNs: "default", specNs: "default", wantRejected: false}, + {name: "cross-namespace target is rejected by new check", triggerNs: "ns-attacker", specNs: "ns-victim", wantRejected: true, wantCrossErr: true}, + } + + r := &KubernetesWatchTrigger{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := r.Validate(makeValidKWT(tc.triggerNs, tc.specNs)) + if !tc.wantRejected { + if err != nil { + t.Fatalf("expected acceptance, got: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected rejection, got nil") + } + if tc.wantCrossErr { + if !strings.Contains(err.Error(), "spec.namespace must equal the trigger namespace") { + t.Fatalf("expected cross-namespace error, got: %v", err) + } + if !strings.Contains(err.Error(), tc.triggerNs) || !strings.Contains(err.Error(), tc.specNs) { + t.Fatalf("error should mention both namespaces (%q and %q), got: %v", tc.triggerNs, tc.specNs, err) + } + } + }) + } +}
pkg/webhook/package.go+12 −0 modified@@ -82,5 +82,17 @@ func (r *Package) Validate(new *v1.Package) error { return ferror.MakeError(ferror.ErrorInvalidArgument, fmt.Sprintf("Package literal larger than %s", humanize.Bytes(uint64(v1.ArchiveLiteralSizeLimit)))) } + + // Refuse Package objects whose spec.environment.namespace targets a + // different namespace than the Package itself. The buildermgr runs with + // a cluster-wide ServiceAccount and would otherwise dispatch a build + // into the referenced Environment's namespace builder, leaking that + // namespace's builder-SA token and Secret/ConfigMap contents back via + // Package.status.buildlog (GHSA-vjhc-cf4p-72q4). + if new.Spec.Environment.Namespace != "" && new.Spec.Environment.Namespace != new.Namespace { + return ferror.MakeError(ferror.ErrorInvalidArgument, + fmt.Sprintf("Package.spec.environment.namespace must equal Package namespace (got env.namespace=%q, pkg.namespace=%q)", + new.Spec.Environment.Namespace, new.Namespace)) + } return nil }
pkg/webhook/package_test.go+74 −0 added@@ -0,0 +1,74 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package webhook + +import ( + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1 "github.com/fission/fission/pkg/apis/core/v1" +) + +// makeValidPackage returns a Package object that satisfies v1.Package.Validate() +// so the cross-namespace branch is the only thing under test. +func makeValidPackage(pkgNs, envNs string) *v1.Package { + return &v1.Package{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pkg-1", + Namespace: pkgNs, + }, + Spec: v1.PackageSpec{ + Environment: v1.EnvironmentReference{ + Name: "env-1", + Namespace: envNs, + }, + }, + Status: v1.PackageStatus{ + BuildStatus: v1.BuildStatusPending, + }, + } +} + +func TestPackageWebhook_Validate_CrossNamespaceEnvironment(t *testing.T) { + cases := []struct { + name string + pkgNs string + envNs string + wantRejected bool + }{ + {name: "empty env.namespace is accepted", pkgNs: "default", envNs: "", wantRejected: false}, + {name: "same namespace is accepted", pkgNs: "default", envNs: "default", wantRejected: false}, + {name: "cross namespace is rejected", pkgNs: "ns-attacker", envNs: "ns-victim", wantRejected: true}, + {name: "cross namespace rejected even when pkg in kube-system", pkgNs: "kube-system", envNs: "default", wantRejected: true}, + } + + r := &Package{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := r.Validate(makeValidPackage(tc.pkgNs, tc.envNs)) + if tc.wantRejected { + if err == nil { + t.Fatalf("expected rejection, got nil") + } + if !strings.Contains(err.Error(), "spec.environment.namespace") { + t.Fatalf("error should reference spec.environment.namespace, got: %v", err) + } + if !strings.Contains(err.Error(), tc.envNs) || !strings.Contains(err.Error(), tc.pkgNs) { + t.Fatalf("error should mention both namespaces (%q and %q), got: %v", tc.pkgNs, tc.envNs, err) + } + } else if err != nil { + t.Fatalf("expected acceptance, got: %v", err) + } + }) + } +}
Vulnerability mechanics
Root cause
"The buildermgr controller did not verify that the Package CRD's environment namespace matched its own metadata namespace."
Attack vector
An attacker with `packages.fission.io/create` permissions in their own namespace can create a Package CRD. By setting `Package.spec.environment.namespace` to a different tenant's namespace, the attacker can cause the controller to dispatch build commands into the victim's builder pod. This allows the attacker to execute arbitrary code within the victim's namespace, potentially exfiltrating sensitive information like service account tokens [ref_id=2].
Affected code
The vulnerability lies within the Fission buildermgr controller, specifically in the `pkg/buildermgr/pkgwatcher.go::build` and `pkg/buildermgr/common.go::buildPackage` functions, which processed Package CRDs without adequate namespace validation. The fix is implemented in `pkg/webhook/package.go` and also within the controller logic mentioned above [patch_id=5504383].
What the fix does
The fix introduces two layers of defense. First, an admission webhook in `pkg/webhook/package.go` now rejects Package CRDs where `Package.spec.environment.namespace` does not match `Package.metadata.namespace`. Second, for defense-in-depth, the controller logic in `pkg/buildermgr/pkgwatcher.go` and `pkg/buildermgr/common.go` also enforces this check before attempting to fetch cross-namespace environments [patch_id=5504383].
Preconditions
- authAttacker must have `packages.fission.io/create` permissions in their own namespace.
- inputAttacker must be able to create a Package CRD with a `spec.environment.namespace` set to a different namespace than `metadata.namespace`.
Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
1- Fission Kubernetes Serverless Framework: 17 Vulnerabilities Disclosed TogetherVypr Intelligence · Jun 10, 2026