Skip to content

Commit e0faaad

Browse files
committed
fix/batches: prevent Git commondir config bypass
1 parent ba67acb commit e0faaad

2 files changed

Lines changed: 35 additions & 2 deletions

File tree

‎internal/batches/workspace/bind_workspace_test.go‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@ func TestDockerBindWorkspace_DiffRestoresTrustedGitConfig(t *testing.T) {
195195
}
196196

197197
dir := *workspace.WorkDir()
198-
configPath := filepath.Join(dir, ".git", "config")
198+
dotGit := filepath.Join(dir, ".git")
199+
configPath := filepath.Join(dotGit, "config")
199200
trustedConfig, err := os.ReadFile(configPath)
200201
if err != nil {
201202
t.Fatal(err)
@@ -210,6 +211,26 @@ func TestDockerBindWorkspace_DiffRestoresTrustedGitConfig(t *testing.T) {
210211
if err := config.Close(); err != nil {
211212
t.Fatal(err)
212213
}
214+
215+
// A commondir file redirects Git to the config in another directory. That
216+
// config must not survive metadata restoration and reach host-side Git.
217+
attackerCommon := filepath.Join(dir, "attacker-common")
218+
if err := os.CopyFS(attackerCommon, os.DirFS(dotGit)); err != nil {
219+
t.Fatal(err)
220+
}
221+
attackerConfig, err := os.OpenFile(filepath.Join(attackerCommon, "config"), os.O_APPEND|os.O_WRONLY, 0)
222+
if err != nil {
223+
t.Fatal(err)
224+
}
225+
if _, err := attackerConfig.WriteString("[filter \"attack\"]\n\tclean = command-that-must-not-run\n\trequired = true\n"); err != nil {
226+
t.Fatal(err)
227+
}
228+
if err := attackerConfig.Close(); err != nil {
229+
t.Fatal(err)
230+
}
231+
if err := os.WriteFile(filepath.Join(dotGit, "commondir"), []byte("../attacker-common\n"), 0644); err != nil {
232+
t.Fatal(err)
233+
}
213234
if err := os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte("*.txt filter=attack\n"), 0644); err != nil {
214235
t.Fatal(err)
215236
}
@@ -231,6 +252,9 @@ func TestDockerBindWorkspace_DiffRestoresTrustedGitConfig(t *testing.T) {
231252
if !cmp.Equal(restoredConfig, trustedConfig) {
232253
t.Fatalf("Git config was not restored:\n%s", cmp.Diff(string(trustedConfig), string(restoredConfig)))
233254
}
255+
if _, err := os.Stat(filepath.Join(dotGit, "commondir")); !os.IsNotExist(err) {
256+
t.Fatalf("untrusted commondir was not removed: %v", err)
257+
}
234258
}
235259

236260
func TestUnzipRejectsGitMetadata(t *testing.T) {

‎internal/batches/workspace/git.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
type gitMetadataSnapshot struct {
1515
dotGit *gitControlFile
16+
commonDir *gitControlFile
1617
config *gitControlFile
1718
configWorktree *gitControlFile
1819
}
@@ -34,7 +35,10 @@ func snapshotGitMetadata(dir string) (*gitMetadataSnapshot, error) {
3435
case info.Mode().IsRegular():
3536
snapshot.dotGit, err = snapshotGitControlFile(dotGit)
3637
case info.IsDir():
37-
snapshot.config, err = snapshotGitControlFile(filepath.Join(dotGit, "config"))
38+
snapshot.commonDir, err = snapshotOptionalGitControlFile(filepath.Join(dotGit, "commondir"))
39+
if err == nil {
40+
snapshot.config, err = snapshotGitControlFile(filepath.Join(dotGit, "config"))
41+
}
3842
if err == nil {
3943
snapshot.configWorktree, err = snapshotOptionalGitControlFile(filepath.Join(dotGit, "config.worktree"))
4044
}
@@ -87,6 +91,11 @@ func (s *gitMetadataSnapshot) restore(dir string) error {
8791
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
8892
return fmt.Errorf("%s is no longer a directory", dotGit)
8993
}
94+
// commondir changes which repository config Git reads. Restore it before
95+
// any host-side Git command can follow an attacker-controlled redirect.
96+
if err := restoreGitControlFile(filepath.Join(dotGit, "commondir"), s.commonDir); err != nil {
97+
return err
98+
}
9099
if err := restoreGitControlFile(filepath.Join(dotGit, "config"), s.config); err != nil {
91100
return err
92101
}

0 commit comments

Comments
 (0)