git: normalize and validate subdir paths

Normalize Git subdir fragments and validate checkout subdir components
so each segment must be a real directory, preventing traversal and symlink escapes.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
(cherry picked from commit 8c994eb561a2646b35352e5663afecd225306214)
This commit is contained in:
Tonis Tiigi committed 2026-03-25 08:14:30 -07:00
1 parent 7cf63df2a7
commit d19ecc730c
5 files changed
+110 -12

No files matched your search

+2
View File
@@ -398,6 +398,8 @@ func Git(url, fragment string, opts ...GitOption) State {
AuthTokenSecret: GitAuthTokenKey,
}
ref, subdir, ok := strings.Cut(fragment, ":")
subdir = path.Join("/", subdir)
subdir = strings.TrimPrefix(subdir, "/")
if ref != "" {
GitRef(ref).SetGitOption(gi)
}
-5
View File
@@ -1,8 +1,6 @@
package git
import (
"path"
"github.com/moby/buildkit/solver/llbsolver/provenance"
provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
"github.com/moby/buildkit/source"
@@ -46,9 +44,6 @@ func NewGitIdentifier(remoteURL string) (*GitIdentifier, error) {
repo.Ref = u.Opts.Ref
repo.Subdir = u.Opts.Subdir
}
if sd := path.Clean(repo.Subdir); sd == "/" || sd == "." {
repo.Subdir = ""
}
return &repo, nil
}
+69 -6
View File
@@ -48,14 +48,14 @@ func TestNewGitIdentifier(t *testing.T) {
expected: GitIdentifier{
Remote: "git://github.com/user/repo.git",
Ref: "mybranch",
Subdir: "mydir/mysubdir/",
Subdir: "mydir/mysubdir",
},
},
{
url: "git://github.com/user/repo.git#:mydir/mysubdir/",
expected: GitIdentifier{
Remote: "git://github.com/user/repo.git",
Subdir: "mydir/mysubdir/",
Subdir: "mydir/mysubdir",
},
},
{
@@ -69,7 +69,7 @@ func TestNewGitIdentifier(t *testing.T) {
expected: GitIdentifier{
Remote: "https://github.com/user/repo.git",
Ref: "mybranch",
Subdir: "mydir/mysubdir/",
Subdir: "mydir/mysubdir",
},
},
{
@@ -83,7 +83,7 @@ func TestNewGitIdentifier(t *testing.T) {
expected: GitIdentifier{
Remote: "git@github.com:user/repo.git",
Ref: "mybranch",
Subdir: "mydir/mysubdir/",
Subdir: "mydir/mysubdir",
},
},
{
@@ -97,7 +97,7 @@ func TestNewGitIdentifier(t *testing.T) {
expected: GitIdentifier{
Remote: "ssh://github.com/user/repo.git",
Ref: "mybranch",
Subdir: "mydir/mysubdir/",
Subdir: "mydir/mysubdir",
},
},
{
@@ -105,7 +105,70 @@ func TestNewGitIdentifier(t *testing.T) {
expected: GitIdentifier{
Remote: "ssh://foo%40barcorp.com@github.com/user/repo.git",
Ref: "mybranch",
Subdir: "mydir/mysubdir/",
Subdir: "mydir/mysubdir",
},
},
{
url: "https://github.com/user/repo.git#main:../../escape",
expected: GitIdentifier{
Remote: "https://github.com/user/repo.git",
Ref: "main",
Subdir: "escape",
},
},
{
url: "https://github.com/user/repo.git#main:dir/../../escape",
expected: GitIdentifier{
Remote: "https://github.com/user/repo.git",
Ref: "main",
Subdir: "escape",
},
},
{
url: "https://github.com/user/repo.git#main:/absolute/path",
expected: GitIdentifier{
Remote: "https://github.com/user/repo.git",
Ref: "main",
Subdir: "absolute/path",
},
},
{
url: "https://github.com/user/repo.git#main:../",
expected: GitIdentifier{
Remote: "https://github.com/user/repo.git",
Ref: "main",
},
},
{
url: "ssh://github.com/user/repo.git#main:../../escape",
expected: GitIdentifier{
Remote: "ssh://github.com/user/repo.git",
Ref: "main",
Subdir: "escape",
},
},
{
url: "ssh://github.com/user/repo.git#main:/absolute/path",
expected: GitIdentifier{
Remote: "ssh://github.com/user/repo.git",
Ref: "main",
Subdir: "absolute/path",
},
},
{
url: "git@github.com:user/repo.git#main:../../escape",
expected: GitIdentifier{
Remote: "git@github.com:user/repo.git",
Ref: "main",
Subdir: "escape",
},
},
{
url: "git@github.com:user/repo.git#main:/absolute/path",
expected: GitIdentifier{
Remote: "git@github.com:user/repo.git",
Ref: "main",
Subdir: "absolute/path",
},
},
}
+36 -1
View File
@@ -993,7 +993,7 @@ func (gs *gitSourceHandler) checkout(ctx context.Context, repo *gitRepo, g sessi
}
}()
subdir := path.Clean(gs.src.Subdir)
subdir := path.Join("/", gs.src.Subdir)
if subdir == "/" {
subdir = "."
}
@@ -1086,6 +1086,11 @@ func (gs *gitSourceHandler) checkout(ctx context.Context, repo *gitRepo, g sessi
}
if subdir != "." {
subdir = filepath.FromSlash(subdir)
if err := validateDirsOnly(cd, subdir); err != nil {
return nil, errors.Wrapf(err, "invalid subdir %v", subdir)
}
d, err := os.Open(filepath.Join(cd, subdir))
if err != nil {
return nil, errors.Wrapf(err, "failed to open subdir %v", subdir)
@@ -1290,3 +1295,33 @@ func gitCLI(opts ...gitutil.Option) *gitutil.GitCLI {
}, opts...)
return gitutil.NewGitCLI(opts...)
}
// validateDirsOnly checks that the given subpath in the repository
// only contains directories without any symlinks or files.
func validateDirsOnly(root string, subpath string) error {
rel := filepath.Clean(subpath)
rel = strings.TrimPrefix(rel, string(filepath.Separator))
if rel == "" || rel == "." {
return nil
}
r, err := os.OpenRoot(root)
if err != nil {
return errors.Wrapf(err, "failed to open root %q", root)
}
defer r.Close()
p := ""
for part := range strings.SplitSeq(rel, string(filepath.Separator)) {
p = filepath.Join(p, part)
fi, err := r.Lstat(p)
if err != nil {
return errors.Wrapf(err, "failed to lstat %q", p)
}
if !fi.IsDir() {
return errors.Errorf("git subpath %q contains non-directory %q", subpath, p)
}
}
return nil
}
+3
View File
@@ -2,6 +2,7 @@ package gitutil
import (
"net/url"
"path"
"regexp"
"strings"
@@ -72,6 +73,8 @@ func parseOpts(fragment string) *GitURLOpts {
return nil
}
ref, subdir, _ := strings.Cut(fragment, ":")
subdir = path.Join("/", subdir)
subdir = strings.TrimPrefix(subdir, "/")
return &GitURLOpts{Ref: ref, Subdir: subdir}
}