VYPR
Low severity3.6NVD Advisory· Published Jun 10, 2026· Updated Jun 10, 2026

CVE-2026-50568

CVE-2026-50568

Description

Fission is an open-source, Kubernetes-native serverless framework that simplifies the deployment of functions and applications on Kubernetes. Prior to version 1.25.0, SanitizeFilePath in pkg/utils/utils.go validated that a path stayed under a safe directory by calling strings.HasPrefix(path, safedir). This is a lexical check, not a directory boundary check: /packages-extra/evil starts with /packages, so it passed. The function did not enforce a path-separator boundary, so any sibling directory whose name began with the safe-directory string was accepted. Callers included the builder's Clean handler (pkg/builder/builder.go:208) and the fetcher's Fetch / Upload handlers (pkg/fetcher/fetcher.go). A tenant who could pre-create or control a sibling directory under the fetcher / builder's shared volume could induce a write or read outside the intended safe directory. This issue has been patched in version 1.25.0.

Affected products

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

Patches

2
5aac6f0bcdf8

chore(utils): remove unused functions from pkg/utils (#3446)

https://github.com/fission/fissionSanket SudakeJun 1, 2026via body-scan-shorthand
4 files changed · +0 284
  • pkg/utils/backoff.go+0 125 removed
    @@ -1,125 +0,0 @@
    -// SPDX-FileCopyrightText: The Fission Authors
    -//
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package utils
    -
    -import (
    -	"fmt"
    -	"time"
    -)
    -
    -const (
    -	DefaultInitialInterval = 500 * time.Millisecond
    -	DefaultMultiplier      = 1.5
    -	DefaultMaxInterval     = 300 * time.Second
    -	DefaultMaxCount        = 30
    -)
    -
    -type backoff struct {
    -	// InitialInterval is the first interval of backoff
    -	InitialInterval time.Duration
    -	// MaxInterval defines the maximum Backoff Interval can be
    -	// After that, NextExist() returns false
    -	MaxInterval time.Duration
    -	// Multiplier defines the multiplier applied on current backoff interval
    -	Multiplier float64
    -	// MaxCount defines the maximum retries to be attempted
    -	// After that, NextExists() returns false
    -	MaxCount       float64
    -	currentbackoff time.Duration
    -	currentCount   float64
    -}
    -
    -// NewBackOff returns a new backoff struct with initialized values
    -func NewBackOff(initialInterval time.Duration, maxInterval time.Duration, multiplier float64, maxCount float64) (*backoff, error) {
    -	if multiplier < 0 || maxInterval < 0 || initialInterval < 0 || maxCount < 0 {
    -		return &backoff{}, fmt.Errorf("negative value for multiplier and max internal not allowed")
    -	}
    -
    -	return &backoff{
    -		MaxInterval:     maxInterval,
    -		Multiplier:      multiplier,
    -		InitialInterval: initialInterval,
    -		MaxCount:        maxCount,
    -		currentbackoff:  initialInterval,
    -		currentCount:    0,
    -	}, nil
    -}
    -
    -// NewDefaultBackOff returns new backoff struct with default values
    -func NewDefaultBackOff() *backoff {
    -	return &backoff{
    -		MaxInterval:     DefaultMaxInterval,
    -		Multiplier:      DefaultMultiplier,
    -		InitialInterval: DefaultInitialInterval,
    -		MaxCount:        DefaultMaxCount,
    -		currentbackoff:  DefaultInitialInterval,
    -		currentCount:    0,
    -	}
    -}
    -
    -// GetMultiplier returns multiplier of current backoff
    -func (backoff *backoff) GetMultiplier() float64 {
    -	return backoff.Multiplier
    -}
    -
    -// GetMaxInterval return MaxInterval of current backoff
    -func (backoff *backoff) GetMaxInterval() time.Duration {
    -	return backoff.MaxInterval
    -}
    -
    -// GetInitialInterval returns the InitialInterval of current backoff
    -func (backoff *backoff) GetInitialInterval() time.Duration {
    -	return backoff.InitialInterval
    -}
    -
    -// GetMaxCount returns the MaxCount of current backoff
    -func (backoff *backoff) GetMaxCount() float64 {
    -	return backoff.MaxCount
    -}
    -
    -// SetMaxCount updates the MaxCount of pre-created backoff
    -func (backoff *backoff) SetMaxCount(maxCount float64) {
    -	backoff.MaxCount = maxCount
    -}
    -
    -// SetMultiplier updates the Multiplier of pre-created backoff
    -func (backoff *backoff) SetMultiplier(multiplier float64) {
    -	backoff.Multiplier = multiplier
    -}
    -
    -// SetMaxInterval updates the MaxInterval of pre-created backoff
    -func (backoff *backoff) SetMaxInterval(maxInterval time.Duration) {
    -	backoff.MaxInterval = maxInterval
    -}
    -
    -// SetInitialInterval updates the InitialInterval of pre-created backoff
    -func (backoff *backoff) SetInitialInterval(initialInterval time.Duration) {
    -	backoff.InitialInterval = initialInterval
    -}
    -
    -// GetCurrentBackoffDuration returns the time.Duration for current backoff time determined
    -func (backoff *backoff) GetCurrentBackoffDuration() time.Duration {
    -	return backoff.currentbackoff
    -}
    -
    -// GetCurrentCount returns the float64 with current retry count
    -func (backoff *backoff) GetCurrentCount() float64 {
    -	return backoff.currentCount
    -}
    -
    -// GetNext returns time.Duration to add sleep for current retry
    -func (backoff *backoff) GetNext() time.Duration {
    -	backoff.currentbackoff = time.Duration(float64(backoff.currentbackoff) * backoff.Multiplier)
    -	backoff.currentCount = backoff.currentCount + 1
    -	return backoff.currentbackoff
    -}
    -
    -// NextExists returns boolean representing the status of next backoff duration
    -func (backoff *backoff) NextExists() bool {
    -	if backoff.currentbackoff*time.Duration(backoff.Multiplier) > backoff.MaxInterval || backoff.currentCount > backoff.MaxCount {
    -		return false
    -	}
    -	return true
    -}
    
  • pkg/utils/backoff_test.go+0 73 removed
    @@ -1,73 +0,0 @@
    -// SPDX-FileCopyrightText: The Fission Authors
    -//
    -// SPDX-License-Identifier: Apache-2.0
    -
    -package utils
    -
    -import (
    -	"testing"
    -	"time"
    -
    -	"github.com/stretchr/testify/assert"
    -	"github.com/stretchr/testify/require"
    -)
    -
    -func TestNewBackOff(t *testing.T) {
    -	t.Parallel()
    -	b, err := NewBackOff(time.Second, 10*time.Second, 2.0, 5)
    -	require.NoError(t, err)
    -	assert.Equal(t, time.Second, b.GetInitialInterval())
    -	assert.Equal(t, 10*time.Second, b.GetMaxInterval())
    -	assert.Equal(t, 2.0, b.GetMultiplier())
    -	// the constructor must honour all four args: MaxCount and the starting
    -	// backoff come from the caller, not from package defaults.
    -	assert.Equal(t, float64(5), b.GetMaxCount())
    -	assert.Equal(t, time.Second, b.GetCurrentBackoffDuration())
    -
    -	_, err = NewBackOff(-1, 10*time.Second, 2.0, 5)
    -	require.Error(t, err, "negative values must be rejected")
    -}
    -
    -func TestNewDefaultBackOff(t *testing.T) {
    -	t.Parallel()
    -	b := NewDefaultBackOff()
    -	assert.Equal(t, DefaultInitialInterval, b.GetInitialInterval())
    -	assert.Equal(t, DefaultMaxInterval, b.GetMaxInterval())
    -	assert.Equal(t, DefaultMultiplier, b.GetMultiplier())
    -	assert.Equal(t, float64(DefaultMaxCount), b.GetMaxCount())
    -	assert.Equal(t, DefaultInitialInterval, b.GetCurrentBackoffDuration())
    -	assert.Equal(t, float64(0), b.GetCurrentCount())
    -}
    -
    -func TestBackoffSetters(t *testing.T) {
    -	t.Parallel()
    -	b := NewDefaultBackOff()
    -	b.SetMaxCount(7)
    -	b.SetMultiplier(3)
    -	b.SetMaxInterval(time.Minute)
    -	b.SetInitialInterval(2 * time.Second)
    -
    -	assert.Equal(t, float64(7), b.GetMaxCount())
    -	assert.Equal(t, 3.0, b.GetMultiplier())
    -	assert.Equal(t, time.Minute, b.GetMaxInterval())
    -	assert.Equal(t, 2*time.Second, b.GetInitialInterval())
    -}
    -
    -func TestBackoffGetNextAndExists(t *testing.T) {
    -	t.Parallel()
    -	b := NewDefaultBackOff()
    -
    -	first := b.GetNext()
    -	assert.Equal(t, float64(1), b.GetCurrentCount())
    -	assert.Equal(t, time.Duration(float64(DefaultInitialInterval)*DefaultMultiplier), first)
    -
    -	second := b.GetNext()
    -	assert.Greater(t, second, first, "backoff grows on each step")
    -	assert.Equal(t, float64(2), b.GetCurrentCount())
    -
    -	assert.True(t, b.NextExists(), "next exists well below the max interval")
    -
    -	// Once current backoff * multiplier exceeds MaxInterval, no next.
    -	b.SetMaxInterval(time.Nanosecond)
    -	assert.False(t, b.NextExists())
    -}
    
  • pkg/utils/informer.go+0 55 modified
    @@ -22,63 +22,8 @@ import (
     	metricsapi "k8s.io/metrics/pkg/apis/metrics"
     
     	fv1 "github.com/fission/fission/pkg/apis/core/v1"
    -	"github.com/fission/fission/pkg/generated/clientset/versioned"
    -	genInformer "github.com/fission/fission/pkg/generated/informers/externalversions"
     )
     
    -func GetInformersForNamespaces(client versioned.Interface, defaultSync time.Duration, kind string) map[string]cache.SharedIndexInformer {
    -	informers := make(map[string]cache.SharedIndexInformer)
    -	for _, ns := range DefaultNSResolver().FissionResourceNS {
    -		factory := genInformer.NewSharedInformerFactoryWithOptions(client, defaultSync, genInformer.WithNamespace(ns)).Core().V1()
    -		switch kind {
    -		case fv1.CanaryConfigResource:
    -			informers[ns] = factory.CanaryConfigs().Informer()
    -		case fv1.EnvironmentResource:
    -			informers[ns] = factory.Environments().Informer()
    -		case fv1.FunctionResource:
    -			informers[ns] = factory.Functions().Informer()
    -		case fv1.HttpTriggerResource:
    -			informers[ns] = factory.HTTPTriggers().Informer()
    -		case fv1.KubernetesWatchResource:
    -			informers[ns] = factory.KubernetesWatchTriggers().Informer()
    -		case fv1.MessageQueueResource:
    -			informers[ns] = factory.MessageQueueTriggers().Informer()
    -		case fv1.PackagesResource:
    -			informers[ns] = factory.Packages().Informer()
    -		case fv1.TimeTriggerResource:
    -			informers[ns] = factory.TimeTriggers().Informer()
    -		default:
    -			panic("Unknown kind: " + kind)
    -		}
    -	}
    -	return informers
    -}
    -
    -func GetK8sInformersForNamespaces(client kubernetes.Interface, defaultSync time.Duration, kind string) map[string]cache.SharedIndexInformer {
    -	informers := make(map[string]cache.SharedIndexInformer)
    -	namespaces := DefaultNSResolver()
    -	for _, ns := range namespaces.FissionNSWithOptions(WithBuilderNs(), WithFunctionNs(), WithDefaultNs()) {
    -		factory := k8sInformers.NewSharedInformerFactoryWithOptions(client, defaultSync, k8sInformers.WithNamespace(ns))
    -		switch kind {
    -		case fv1.Deployments:
    -			informers[ns] = factory.Apps().V1().Deployments().Informer()
    -		case fv1.ReplicaSets:
    -			informers[ns] = factory.Apps().V1().ReplicaSets().Informer()
    -		case fv1.Pods:
    -			informers[ns] = factory.Core().V1().Pods().Informer()
    -		case fv1.Services:
    -			informers[ns] = factory.Core().V1().Services().Informer()
    -		case fv1.ConfigMaps:
    -			informers[ns] = factory.Core().V1().ConfigMaps().Informer()
    -		case fv1.Secrets:
    -			informers[ns] = factory.Core().V1().Secrets().Informer()
    -		default:
    -			panic("Unknown kind: " + kind)
    -		}
    -	}
    -	return informers
    -}
    -
     func GetInformerEventChecker(ctx context.Context, client kubernetes.Interface, reason string) map[string]cache.SharedInformer {
     	informers := make(map[string]cache.SharedInformer)
     	namespaces := DefaultNSResolver()
    
  • pkg/utils/utils.go+0 31 modified
    @@ -40,12 +40,6 @@ func UrlForFunction(name, namespace string) string {
     	return fmt.Sprintf("%s/%s", prefix, name)
     }
     
    -// IsNetworkError returns true if an error is a network error, and false otherwise.
    -func IsNetworkError(err error) bool {
    -	_, ok := err.(net.Error)
    -	return ok
    -}
    -
     // GetFunctionIstioServiceName return service name of function for istio feature
     func GetFunctionIstioServiceName(fnName, fnNamespace string) string {
     	return fmt.Sprintf("istio-%s-%s", fnName, fnNamespace)
    @@ -287,28 +281,3 @@ func IsOwnerReferencesEnabled() bool {
     	disableOwnerReference, _ := strconv.ParseBool(os.Getenv(ENV_DISABLE_OWNER_REFERENCES))
     	return !disableOwnerReference
     }
    -
    -// SanitizeFilePath checks if the path is valid to prevent directory traversal attacks.
    -//
    -// Deprecated: prefer RootJoin (to validate a path) or the Root* helpers (to
    -// perform an os.Root-confined operation). Those validate via os.Root semantics
    -// and are recognized by static analysis (CodeQL go/path-injection) as a
    -// traversal barrier, which this Clean+HasPrefix check is not.
    -func SanitizeFilePath(path string, safedir string) (string, error) {
    -	if len(path) == 0 {
    -		return "", errors.New("invalid path")
    -	}
    -	if len(safedir) == 0 {
    -		return "", errors.New("invalid safe directory")
    -	}
    -	// get normalized path and check for directory traversal attacks
    -	normalizedPath := filepath.Clean(path)
    -	if normalizedPath != path {
    -		return "", errors.New("invalid path")
    -	}
    -	// check if the path is under the safe directory
    -	if !strings.HasPrefix(normalizedPath, safedir) {
    -		return "", fmt.Errorf("path %s is not under the safe directory %s", normalizedPath, safedir)
    -	}
    -	return normalizedPath, nil
    -}
    
8298e33ea745

refactor(fetcher,builder): confine shared-volume FS ops with os.Root helpers (#3445)

https://github.com/fission/fissionSanket SudakeJun 1, 2026via body-scan-shorthand
6 files changed · +249 23
  • pkg/builder/builder.go+5 6 modified
    @@ -14,7 +14,6 @@ import (
     	"os"
     	"os/exec"
     	"path"
    -	"path/filepath"
     	"strings"
     	"time"
     
    @@ -154,14 +153,14 @@ func (builder *Builder) Handler(w http.ResponseWriter, r *http.Request) {
     		"buildCommandLen", len(req.BuildCommand))
     
     	logger.V(1).Info("starting build")
    -	srcPkgPath, err := utils.SanitizeFilePath(filepath.Join(builder.sharedVolumePath, req.SrcPkgFilename), builder.sharedVolumePath)
    +	srcPkgPath, err := utils.RootJoin(builder.sharedVolumePath, req.SrcPkgFilename)
     	if err != nil {
     		logger.Error(err, "filename", req.SrcPkgFilename)
     		builder.reply(r.Context(), w, "", err.Error(), http.StatusBadRequest)
     		return
     	}
     	deployPkgFilename := fmt.Sprintf("%s-%s", req.SrcPkgFilename, strings.ToLower(uniuri.NewLen(6)))
    -	deployPkgPath, err := utils.SanitizeFilePath(filepath.Join(builder.sharedVolumePath, deployPkgFilename), builder.sharedVolumePath)
    +	deployPkgPath, err := utils.RootJoin(builder.sharedVolumePath, deployPkgFilename)
     	if err != nil {
     		logger.Error(err, "filename", req.SrcPkgFilename)
     		builder.reply(r.Context(), w, "", err.Error(), http.StatusBadRequest)
    @@ -205,7 +204,7 @@ func (builder *Builder) Clean(w http.ResponseWriter, r *http.Request) {
     	}()
     
     	srcPkgFilename := r.URL.Query().Get("name")
    -	srcPkgPath, err := utils.SanitizeFilePath(filepath.Join(builder.sharedVolumePath, srcPkgFilename), builder.sharedVolumePath)
    +	srcPkgPath, err := utils.RootJoin(builder.sharedVolumePath, srcPkgFilename)
     	if err != nil {
     		logger.Error(err, "rejecting clean request", "source_package", srcPkgFilename)
     		builder.reply(r.Context(), w, srcPkgFilename, fmt.Sprintf("error: invalid name: %s", err.Error()), http.StatusBadRequest)
    @@ -255,9 +254,9 @@ func (builder *Builder) build(ctx context.Context, command string, args []string
     
     	cmd := exec.Command(command, args...)
     
    -	fi, err := os.Stat(srcPkgPath)
    +	fi, err := utils.RootStat(builder.sharedVolumePath, srcPkgPath)
     	if err != nil {
    -		return "", fmt.Errorf("could not find srcPkgPath: '%s'", srcPkgPath)
    +		return "", fmt.Errorf("could not find srcPkgPath '%s': %w", srcPkgPath, err)
     	}
     	if fi.IsDir() {
     		cmd.Dir = srcPkgPath
    
  • pkg/fetcher/fetcher.go+23 16 modified
    @@ -177,11 +177,17 @@ func verifyChecksum(fileChecksum, checksum *fv1.Checksum) error {
     }
     
     func writeSecretOrConfigMap(dataMap map[string][]byte, dirPath string) error {
    +	// Open the os.Root once and write every key through it: os.Root confines
    +	// each write to dirPath (the keys are Secret/ConfigMap data keys), and
    +	// reusing one root avoids an openat per key.
    +	root, err := os.OpenRoot(dirPath)
    +	if err != nil {
    +		return fmt.Errorf("failed to open directory %s: %w", dirPath, err)
    +	}
    +	defer root.Close()
     	for key, val := range dataMap {
    -		writeFilePath := filepath.Join(dirPath, key)
    -		err := os.WriteFile(writeFilePath, val, 0750)
    -		if err != nil {
    -			return fmt.Errorf("failed to write file %s: %w", writeFilePath, err)
    +		if err := root.WriteFile(key, val, 0750); err != nil {
    +			return fmt.Errorf("failed to write file %s in %s: %w", key, dirPath, err)
     		}
     	}
     	return nil
    @@ -293,22 +299,22 @@ func (fetcher *Fetcher) SpecializeHandler(w http.ResponseWriter, r *http.Request
     func (fetcher *Fetcher) Fetch(ctx context.Context, pkg *fv1.Package, req FunctionFetchRequest) (int, error) {
     	logger := otelUtils.LoggerWithTraceID(ctx, fetcher.logger)
     
    -	storePath, err := utils.SanitizeFilePath(filepath.Join(fetcher.sharedVolumePath, req.Filename), fetcher.sharedVolumePath)
    +	storePath, err := utils.RootJoin(fetcher.sharedVolumePath, req.Filename)
     	if err != nil {
     		logger.Error(err, "filename", req.Filename)
     		return http.StatusBadRequest, fmt.Errorf("%s, request: %v", err, req)
     	}
     
     	// verify first if the file already exists.
    -	if _, err := os.Stat(storePath); err == nil {
    +	if _, err := utils.RootStat(fetcher.sharedVolumePath, storePath); err == nil {
     		logger.Info("requested file already exists at shared volume - skipping fetch",
     			"requested_file", req.Filename,
     			"shared_volume_path", fetcher.sharedVolumePath)
     		otelUtils.SpanTrackEvent(ctx, "packageAlreadyExists", otelUtils.GetAttributesForPackage(pkg)...)
     		return http.StatusOK, nil
     	}
     
    -	tmpPath, err := utils.SanitizeFilePath(storePath+".tmp", fetcher.sharedVolumePath)
    +	tmpPath, err := utils.RootJoin(fetcher.sharedVolumePath, storePath+".tmp")
     	if err != nil {
     		logger.Error(err, "filename", req.Filename)
     		return http.StatusBadRequest, fmt.Errorf("%s, request: %v", err, req)
    @@ -355,7 +361,7 @@ func (fetcher *Fetcher) Fetch(ctx context.Context, pkg *fv1.Package, req Functio
     		// get package data as literal or by url
     		if len(archive.Literal) > 0 {
     			// write pkg.Literal into tmpPath
    -			err := os.WriteFile(tmpPath, archive.Literal, 0600)
    +			err := utils.RootWriteFile(fetcher.sharedVolumePath, tmpPath, archive.Literal, 0600)
     			if err != nil {
     				e := "failed to write file"
     				logger.Error(err, e, "location", tmpPath)
    @@ -447,13 +453,13 @@ func (fetcher *Fetcher) FetchSecretsAndCfgMaps(ctx context.Context, secrets []fv
     				return httpCode, errors.New(e)
     			}
     
    -			secretDir, err := utils.SanitizeFilePath(filepath.Join(fetcher.sharedSecretPath, secret.Namespace, secret.Name), fetcher.sharedSecretPath)
    +			secretDir, err := utils.RootJoin(fetcher.sharedSecretPath, filepath.Join(secret.Namespace, secret.Name))
     			if err != nil {
     				logger.Error(err, "directory", secretDir, "secret_name", secret.Name, "secret_namespace", secret.Namespace)
     				return http.StatusBadRequest, fmt.Errorf("%s, request: %v", err, secret)
     			}
     
    -			err = os.MkdirAll(secretDir, os.ModeDir|0750)
    +			err = utils.RootMkdirAll(fetcher.sharedSecretPath, secretDir, 0750)
     			if err != nil {
     				e := "failed to create directory for secret"
     				logger.Error(err, e, "directory", secretDir,
    @@ -493,14 +499,14 @@ func (fetcher *Fetcher) FetchSecretsAndCfgMaps(ctx context.Context, secrets []fv
     				return httpCode, errors.New(e)
     			}
     
    -			configDir, err := utils.SanitizeFilePath(filepath.Join(fetcher.sharedConfigPath, config.Namespace, config.Name), fetcher.sharedConfigPath)
    +			configDir, err := utils.RootJoin(fetcher.sharedConfigPath, filepath.Join(config.Namespace, config.Name))
     			if err != nil {
     				logger.Error(err, "directory", configDir, "config_map_name", config.Name, "config_map_namespace", config.Namespace)
     				return http.StatusBadRequest, fmt.Errorf("%s, request: %v", err,
     					config)
     			}
     
    -			err = os.MkdirAll(configDir, os.ModeDir|0750)
    +			err = utils.RootMkdirAll(fetcher.sharedConfigPath, configDir, 0750)
     			if err != nil {
     				e := "failed to create directory for configmap"
     				logger.Error(err, e, "directory", configDir,
    @@ -562,13 +568,13 @@ func (fetcher *Fetcher) UploadHandler(w http.ResponseWriter, r *http.Request) {
     
     	logger.Info("fetcher received upload request", "request", req)
     
    -	srcFilepath, err := utils.SanitizeFilePath(filepath.Join(fetcher.sharedVolumePath, req.Filename), fetcher.sharedVolumePath)
    +	srcFilepath, err := utils.RootJoin(fetcher.sharedVolumePath, req.Filename)
     	if err != nil {
     		logger.Error(err, "error sanitizing file path")
     		http.Error(w, fmt.Sprintf("%s: %v", err, req.Filename), http.StatusBadRequest)
     		return
     	}
    -	dstFilepath, err := utils.SanitizeFilePath(filepath.Join(fetcher.sharedVolumePath, req.Filename+".zip"), fetcher.sharedVolumePath)
    +	dstFilepath, err := utils.RootJoin(fetcher.sharedVolumePath, req.Filename+".zip")
     	if err != nil {
     		logger.Error(err, "error sanitizing file path")
     		http.Error(w, fmt.Sprintf("%s: %v", err, req.Filename), http.StatusBadRequest)
    @@ -592,7 +598,7 @@ func (fetcher *Fetcher) UploadHandler(w http.ResponseWriter, r *http.Request) {
     			return
     		}
     	} else {
    -		err = os.Rename(srcFilepath, dstFilepath)
    +		err = utils.RootRename(fetcher.sharedVolumePath, srcFilepath, dstFilepath)
     		if err != nil {
     			e := "error renaming the archive"
     			logger.Error(err, e, "source", srcFilepath, "destination", dstFilepath)
    @@ -646,7 +652,8 @@ func (fetcher *Fetcher) UploadHandler(w http.ResponseWriter, r *http.Request) {
     }
     
     func (fetcher *Fetcher) rename(src string, dst string) error {
    -	err := os.Rename(src, dst)
    +	// src and dst are always under the shared volume; confine the rename to it.
    +	err := utils.RootRename(fetcher.sharedVolumePath, src, dst)
     	if err != nil {
     		return fmt.Errorf("failed to move file: %w", err)
     	}
    
  • pkg/fetcher/fetcher_test.go+2 1 modified
    @@ -53,8 +53,9 @@ func TestMakeVolumeDir(t *testing.T) {
     
     func TestRename(t *testing.T) {
     	t.Parallel()
    -	f := &Fetcher{logger: loggerfactory.GetLogger()}
     	dir := t.TempDir()
    +	// rename is confined to the shared volume; src and dst live under it.
    +	f := &Fetcher{logger: loggerfactory.GetLogger(), sharedVolumePath: dir}
     	src := filepath.Join(dir, "src")
     	dst := filepath.Join(dir, "dst")
     	require.NoError(t, os.WriteFile(src, []byte("x"), 0600))
    
  • pkg/utils/root.go+115 0 added
    @@ -0,0 +1,115 @@
    +// SPDX-FileCopyrightText: The Fission Authors
    +//
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package utils
    +
    +import (
    +	"fmt"
    +	"os"
    +	"path/filepath"
    +	"strings"
    +)
    +
    +// relUnderRoot converts a base-relative or absolute-under-base path into a name
    +// relative to base, rejecting absolute-escape and ".." traversal. It mirrors the
    +// os.Root relName contract used by pkg/storagesvc and is the validation behind
    +// the Root* helpers below.
    +func relUnderRoot(base, p string) (string, error) {
    +	if base == "" {
    +		return "", fmt.Errorf("empty base directory")
    +	}
    +	name := filepath.FromSlash(p)
    +	if filepath.IsAbs(name) {
    +		rel, err := filepath.Rel(base, name)
    +		if err != nil {
    +			return "", fmt.Errorf("path %q is outside base %q", p, base)
    +		}
    +		name = rel
    +	}
    +	name = filepath.Clean(name)
    +	// ".." (and "../...") escape the base; reject them. "." (the base itself)
    +	// is inside the root and is allowed, matching the previous SanitizeFilePath
    +	// behavior for an empty/base-resolving path.
    +	if name == ".." || strings.HasPrefix(name, ".."+string(os.PathSeparator)) {
    +		return "", fmt.Errorf("path %q escapes base %q", p, base)
    +	}
    +	return name, nil
    +}
    +
    +// RootJoin validates that name resolves to a location under base (no absolute
    +// escape, no ".." traversal) and returns the joined, cleaned path under base —
    +// absolute when base is absolute, as it always is for the callers here. It is
    +// the os.Root-style replacement for SanitizeFilePath: the result can be handed
    +// to downstream consumers unchanged.
    +func RootJoin(base, name string) (string, error) {
    +	rel, err := relUnderRoot(base, name)
    +	if err != nil {
    +		return "", err
    +	}
    +	return filepath.Join(base, rel), nil
    +}
    +
    +// RootStat stats path within base through an os.Root, so a traversing path
    +// cannot reach a file outside base. path may be base-relative or
    +// absolute-under-base.
    +func RootStat(base, path string) (os.FileInfo, error) {
    +	rel, err := relUnderRoot(base, path)
    +	if err != nil {
    +		return nil, err
    +	}
    +	root, err := os.OpenRoot(base)
    +	if err != nil {
    +		return nil, err
    +	}
    +	defer root.Close()
    +	return root.Stat(rel)
    +}
    +
    +// RootWriteFile writes data to path within base through an os.Root.
    +func RootWriteFile(base, path string, data []byte, perm os.FileMode) error {
    +	rel, err := relUnderRoot(base, path)
    +	if err != nil {
    +		return err
    +	}
    +	root, err := os.OpenRoot(base)
    +	if err != nil {
    +		return err
    +	}
    +	defer root.Close()
    +	return root.WriteFile(rel, data, perm)
    +}
    +
    +// RootMkdirAll creates path (and parents) within base through an os.Root. perm
    +// must be permission bits only (no os.ModeDir), which os.Root.MkdirAll requires.
    +func RootMkdirAll(base, path string, perm os.FileMode) error {
    +	rel, err := relUnderRoot(base, path)
    +	if err != nil {
    +		return err
    +	}
    +	root, err := os.OpenRoot(base)
    +	if err != nil {
    +		return err
    +	}
    +	defer root.Close()
    +	return root.MkdirAll(rel, perm.Perm())
    +}
    +
    +// RootRename renames oldPath to newPath through an os.Root. Both ends must be
    +// under the same base.
    +func RootRename(base, oldPath, newPath string) error {
    +	relOld, err := relUnderRoot(base, oldPath)
    +	if err != nil {
    +		return err
    +	}
    +	relNew, err := relUnderRoot(base, newPath)
    +	if err != nil {
    +		return err
    +	}
    +	root, err := os.OpenRoot(base)
    +	if err != nil {
    +		return err
    +	}
    +	defer root.Close()
    +	return root.Rename(relOld, relNew)
    +}
    
  • pkg/utils/root_test.go+99 0 added
    @@ -0,0 +1,99 @@
    +// SPDX-FileCopyrightText: The Fission Authors
    +//
    +// SPDX-License-Identifier: Apache-2.0
    +
    +package utils
    +
    +import (
    +	"os"
    +	"path/filepath"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func TestRelUnderRoot(t *testing.T) {
    +	t.Parallel()
    +	base := filepath.Clean("/packages")
    +
    +	ok := []struct{ in, want string }{
    +		{"file.txt", "file.txt"},
    +		{"sub/file.txt", filepath.Join("sub", "file.txt")},
    +		{"/packages/file.txt", "file.txt"},
    +		{"/packages/sub/file.txt", filepath.Join("sub", "file.txt")},
    +		{"foo/../bar", "bar"}, // resolves to an in-base path; not traversal
    +		{".", "."},            // the base itself is allowed
    +		{"/packages", "."},    // base by absolute path
    +	}
    +	for _, c := range ok {
    +		got, err := relUnderRoot(base, c.in)
    +		require.NoError(t, err, "input %q", c.in)
    +		assert.Equal(t, c.want, got, "input %q", c.in)
    +	}
    +
    +	bad := []string{"../escape", "../../escape", "/etc/passwd", "sub/../../escape"}
    +	for _, in := range bad {
    +		_, err := relUnderRoot(base, in)
    +		assert.Error(t, err, "input %q should be rejected", in)
    +	}
    +}
    +
    +func TestRootHelpersConfineToBase(t *testing.T) {
    +	t.Parallel()
    +
    +	t.Run("write/stat/rename happy path", func(t *testing.T) {
    +		t.Parallel()
    +		base := t.TempDir()
    +
    +		require.NoError(t, RootWriteFile(base, "a.txt", []byte("alpha"), 0o600))
    +		fi, err := RootStat(base, "a.txt")
    +		require.NoError(t, err)
    +		assert.Equal(t, int64(5), fi.Size())
    +
    +		// absolute-under-base form also works (parent created first, since
    +		// RootWriteFile does not create parents — same as os.WriteFile)
    +		require.NoError(t, RootMkdirAll(base, "nested", 0o755))
    +		require.NoError(t, RootWriteFile(base, filepath.Join(base, "nested", "b.txt"), []byte("beta"), 0o600))
    +		require.NoError(t, RootMkdirAll(base, "d1/d2", 0o755))
    +
    +		require.NoError(t, RootRename(base, "a.txt", "renamed.txt"))
    +		_, err = RootStat(base, "a.txt")
    +		assert.Error(t, err)
    +		fi, err = RootStat(base, "renamed.txt")
    +		require.NoError(t, err)
    +		assert.Equal(t, int64(5), fi.Size())
    +	})
    +
    +	t.Run("escapes are rejected and leave the filesystem untouched", func(t *testing.T) {
    +		t.Parallel()
    +		root := t.TempDir()
    +		base := filepath.Join(root, "base")
    +		require.NoError(t, os.MkdirAll(base, 0o755))
    +		sentinel := filepath.Join(root, "sentinel")
    +		require.NoError(t, os.WriteFile(sentinel, []byte("intact"), 0o600))
    +
    +		assert.Error(t, RootWriteFile(base, "../sentinel", []byte("pwned"), 0o600))
    +		assert.Error(t, RootWriteFile(base, sentinel, []byte("pwned"), 0o600))
    +		assert.Error(t, RootMkdirAll(base, "../evil", 0o755))
    +		_, err := RootStat(base, "../sentinel")
    +		assert.Error(t, err)
    +		assert.Error(t, RootRename(base, "../sentinel", "x"))
    +
    +		// sentinel outside base is untouched, and no escape dir was created
    +		got, err := os.ReadFile(sentinel)
    +		require.NoError(t, err)
    +		assert.Equal(t, "intact", string(got))
    +		assert.NoDirExists(t, filepath.Join(root, "evil"))
    +	})
    +
    +	t.Run("RootJoin returns validated absolute path", func(t *testing.T) {
    +		t.Parallel()
    +		base := filepath.Clean("/packages")
    +		got, err := RootJoin(base, "sub/file.txt")
    +		require.NoError(t, err)
    +		assert.Equal(t, filepath.Join(base, "sub", "file.txt"), got)
    +		_, err = RootJoin(base, "../escape")
    +		assert.Error(t, err)
    +	})
    +}
    
  • pkg/utils/utils.go+5 0 modified
    @@ -289,6 +289,11 @@ func IsOwnerReferencesEnabled() bool {
     }
     
     // SanitizeFilePath checks if the path is valid to prevent directory traversal attacks.
    +//
    +// Deprecated: prefer RootJoin (to validate a path) or the Root* helpers (to
    +// perform an os.Root-confined operation). Those validate via os.Root semantics
    +// and are recognized by static analysis (CodeQL go/path-injection) as a
    +// traversal barrier, which this Clean+HasPrefix check is not.
     func SanitizeFilePath(path string, safedir string) (string, error) {
     	if len(path) == 0 {
     		return "", errors.New("invalid path")
    

Vulnerability mechanics

Root cause

"The SanitizeFilePath function performed a lexical check instead of a directory boundary check, allowing path traversal."

Attack vector

A tenant with the ability to pre-create or control a sibling directory under the fetcher or builder's shared volume could exploit this vulnerability. By manipulating paths, they could induce reads or writes outside the intended safe directory. This requires an authenticated Kubernetes user and the pre-existence of a specific sibling directory on the filesystem [ref_id=2]. The vulnerability is triggered by controlling input paths that are processed by the builder's Clean handler or the fetcher's Fetch/Upload handlers [ref_id=2].

Affected code

The vulnerability resides in the `SanitizeFilePath` function within `pkg/utils/utils.go` [ref_id=2]. This function was called by handlers in `pkg/builder/builder.go` and `pkg/fetcher/fetcher.go` [ref_id=2]. The fix involves migrating these call sites to new `os.Root`-confined helpers introduced in `pkg/utils/root.go` [patch_id=5504358].

What the fix does

The patch replaces the `SanitizeFilePath` function with new `os.Root`-confined helpers in `pkg/utils/root.go` [patch_id=5504358]. These helpers, such as `RootJoin`, `RootStat`, and `RootWriteFile`, enforce directory confinement at the kernel level, which is recognized as a traversal barrier by static analysis tools [ref_id=1]. This change ensures that filesystem operations are strictly limited to the intended base directory, preventing sibling-directory escapes. The deprecated `SanitizeFilePath` function was subsequently removed once all call sites were migrated [patch_id=5504359].

Preconditions

  • authRequires an authenticated Kubernetes user.
  • configRequires a sibling directory to the safe directory to exist on the filesystem, which the attacker can pre-create or control.

Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

1