CVE-2026-49822
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, a low-privilege developer who could create a KubernetesWatchTrigger (KWT) in their own namespace was able to establish a persistent surveillance channel over any other 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 KubernetesWatchTrigger allowed specifying a namespace different from its own, enabling cross-namespace surveillance."
Attack vector
A low-privilege developer with the ability to create a KubernetesWatchTrigger (KWT) in their own namespace could exploit this vulnerability. By setting the KWT's `spec.namespace` to a target namespace, they could establish a persistent surveillance channel. This channel would allow them to receive serialized event payloads for Pods, Services, and Jobs from any namespace, requiring no additional privileges beyond KWT creation permissions [ref_id=2].
Affected code
The vulnerability lies within the `pkg/webhook/kuberneteswatchtrigger.go` file, specifically in the validating webhook logic. The `pkg/kubewatcher/kubewatcher.go` file is also implicated, as its `createKubernetesWatch` function used the user-controlled `w.Spec.Namespace` directly without proper validation against the KWT's own namespace (`w.Namespace`) [ref_id=2].
What the fix does
The patch extends the validating webhook to cover both create and update verbs for KubernetesWatchTriggers. It now rejects KWTs where `spec.namespace` differs from `metadata.namespace`. Additionally, a controller guard in `createKubernetesWatch` prevents cross-namespace targets that might bypass admission, and an empty `Spec.Namespace` is coerced to the trigger's own namespace instead of watching all namespaces [ref_id=1][ref_id=2][patch_id=5504381].
Preconditions
- authAttacker must have permission to create KubernetesWatchTriggers in their own namespace.
- inputAttacker must be able to control the `spec.namespace` field of the KubernetesWatchTrigger.
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