Root cause
Path & link handling
Harden builder Clean handler and BuildCommand allowlist
How AI contributed: New attack surfacePublished Jul 28, 2026 in
fission/fissionAll 3 findings in this repository
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. Affected · Project: github.com/fission/fission · Versions: all versions through v1.24.0 with SanitizeFilePath in the tree · Audited commit: 647c141 · Component: pkg/utils/utils.go:SanitizeFilePath · Callers: pkg/builder/builder.go:157,164,208, pkg/fetcher/fetcher.go:296,311,450,496,565,571 · Configuration: default; requires a sibling directory to the safe dir to exist on the filesystem Fix section (paste into the Fix / Patches field) Fixed in v1.25.0 by: · PR #3445 (commit 8298e33e) — migrate every SanitizeFilePath call site (fetcher: storePath / tmpPath / secretDir / configDir / rename + writeSecretOrConfigMap; builder: srcPkg / deployPkg path validation and srcPkg stat) to new pkg/utils/root.go helpers (RootJoin, RootStat, RootWriteFile, RootMkdirAll, RootRename) that operate through os.Root. os.Root enforces directory confinement in the kernel and is recognized by CodeQL go/path-injection as a traversal barrier. · PR #3446 (commit 5aac6f0b) — delete the deprecated SanitizeFilePath itself once no callers remained. The vulnerable function no longer exists in the tree.
Path confinement rested on SanitizeFilePath in pkg/utils/utils.go, whose only check was strings.HasPrefix against the safe directory, so a sibling such as /packages-extra/evil passed the /packages prefix used by the builder Clean handler and by the fetcher Fetch and Upload handlers. The fix moves every call site onto RootJoin, RootStat, RootWriteFile, RootMkdirAll, and RootRename so os.Root enforces the boundary in the kernel.
Root cause
Harden builder Clean handler and BuildCommand allowlist
Fix
refactor(fetcher,builder): confine shared-volume FS ops with os.Root helpers (#3445)
Case facts
--- a/pkg/builder/builder.go+++ b/pkg/builder/builder.go@@ -42,8 +42,18 @@ const ( // supported environment variables envSrcPkg string = "SRC_PKG" envDeployPkg string = "DEPLOY_PKG"++ // defaultBuildCommand is invoked when the request omits a buildCommand.+ defaultBuildCommand = "/build" ) +// shellMetacharacters are characters that can change the meaning of a command+// when interpreted by a shell. The builder uses os/exec which does not invoke a+// shell, so these characters can never serve a legitimate purpose in a build+// command — rejecting them up front closes off the command-injection attack+// surface even if a downstream caller ever wraps the command in `sh -c`.+const shellMetacharacters = ";|&`$()<>\n\r"+ type ( PackageBuildRequest struct { SrcPkgFilename string `json:"srcPkgFilename"`@@ -73,6 +83,24 @@ func MakeBuilder(logger logr.Logger, sharedVolumePath string) *Builder { } } +// resolveBuildCommand parses a build-command string supplied by the caller.+// An empty string falls back to defaultBuildCommand with no arguments.+// A non-empty string is split on whitespace; the first token is the executable+// and the remainder are arguments. Shell metacharacters anywhere in the input+// are rejected — os/exec does not invoke a shell, so they can only ever be a+// confused-deputy attempt.+func resolveBuildCommand(cmd string) (string, []string, error) {+ cmd = strings.TrimSpace(cmd)+ if cmd == "" {+ return defaultBuildCommand, nil, nil+ }+ if i := strings.IndexAny(cmd, shellMetacharacters); i >= 0 {+ return "", nil, fmt.Errorf("contains shell metacharacter %q", cmd[i:i+1])+ }+ parts := strings.Fields(cmd)+ return parts[0], parts[1:], nil+}+ func (builder *Builder) VersionHandler(w http.ResponseWriter, r *http.Request) { logger := otelUtils.LoggerWithTraceID(r.Context(), builder.logger) @@ -133,20 +161,11 @@ func (builder *Builder) Handler(w http.ResponseWriter, r *http.Request) { return } - var buildArgs []string- buildCmd := req.BuildCommand- if len(buildCmd) == 0 {- // use default build command- buildCmd = "/build"- } else {- // split executable command and arguments- args := strings.Split(buildCmd, " ")- buildCmd = args[0] // get the executable command, executable command will always be on Zero index-- // get all the arguments- for i := 1; i < len(args); i++ {- buildArgs = append(buildArgs, args[i])- }+ buildCmd, buildArgs, err := resolveBuildCommand(req.BuildCommand)+ if err != nil {+ logger.Error(err, "rejecting build request")+ builder.reply(r.Context(), w, "", fmt.Sprintf("error: invalid buildCommand: %s", err.Error()), http.StatusBadRequest)+ return } buildLogs, err := builder.build(r.Context(), buildCmd, buildArgs, srcPkgPath, deployPkgPath) if err != nil {@@ -179,11 +198,16 @@ func (builder *Builder) Clean(w http.ResponseWriter, r *http.Request) { }() srcPkgFilename := r.URL.Query().Get("name")- srcPkgPath := filepath.Join(builder.sharedVolumePath, srcPkgFilename)+ srcPkgPath, err := utils.SanitizeFilePath(filepath.Join(builder.sharedVolumePath, srcPkgFilename), builder.sharedVolumePath)+ 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)+ return+ } logger.Info("builder received clean request", "source_package", srcPkgFilename) - err := utils.DeleteOldPackages(srcPkgPath, envSrcPkg)+ err = utils.DeleteOldPackages(srcPkgPath, envSrcPkg) if err != nil { e := "error deleting src package after build" logger.Error(err, e)--- a/pkg/builder/builder.go+++ b/pkg/builder/builder.go@@ -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 = srcPkgPathAI-assisted change f74c012da106ae356d623bafff56a2a9c78bc8593f4661fe85582ef1dafdc17b · Fix bd2d60e530789f7a802f43bc69e77f118ab7d1997c9b68328b9c44ea71718bd9