Root cause
AI change
Harden builder Clean handler and BuildCommand allowlist
Loading…
How AI contributed
New attack surfaceSanitizeFilePath 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.
Root cause
Harden builder Clean handler and BuildCommand allowlist
Fix
refactor(fetcher,builder): confine shared-volume FS ops with os.Root helpers (#3445)
Code comparison
--- 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 = srcPkgPathCandidate f74c012da106ae356d623bafff56a2a9c78bc8593f4661fe85582ef1dafdc17b · Fix bd2d60e530789f7a802f43bc69e77f118ab7d1997c9b68328b9c44ea71718bd9
Releases
Advisory references