VYPR
High severity7.7NVD Advisory· Published Jun 10, 2026· Updated Jun 10, 2026

CVE-2026-49822

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

2
  • Fission/Fissionreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <1.24.0

Patches

1
e2b92663499f

Reject cross-namespace references in Package + KubernetesWatchTrigger (GHSA-vjhc, GHSA-gc3j) (#3379)

https://github.com/fission/fissionSanket SudakeMay 15, 2026via body-scan-shorthand
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

3

News mentions

1