Skip to content

Commit 5e4aec2

Browse files
authored
security: use os.Root API for file path logic (#1279)
1 parent 0e5e006 commit 5e4aec2

File tree

5 files changed

+165
-38
lines changed

5 files changed

+165
-38
lines changed

internal/batches/executor/executor_test.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func TestExecutor_Integration(t *testing.T) {
5050

5151
// create a temp directory with a simple shell file
5252
tempDir := t.TempDir()
53-
mountScript := fmt.Sprintf("%s/sample.sh", tempDir)
53+
mountScript := filepath.Join(tempDir, "sample.sh")
5454
err := os.WriteFile(mountScript, []byte(`echo -e "foobar\n" >> README.md`), 0777)
5555
require.NoError(t, err)
5656

@@ -80,7 +80,8 @@ func TestExecutor_Integration(t *testing.T) {
8080

8181
wantCacheCount int
8282

83-
failFast bool
83+
failFast bool
84+
workingDirectory string
8485
}{
8586
{
8687
name: "success",
@@ -362,7 +363,7 @@ func TestExecutor_Integration(t *testing.T) {
362363
steps: []batcheslib.Step{
363364
{
364365
Run: mountScript,
365-
Mount: []batcheslib.Mount{{Path: mountScript, Mountpoint: mountScript}},
366+
Mount: []batcheslib.Mount{{Path: "sample.sh", Mountpoint: mountScript}},
366367
},
367368
},
368369
tasks: []*Task{
@@ -373,8 +374,9 @@ func TestExecutor_Integration(t *testing.T) {
373374
rootPath: []string{"README.md"},
374375
},
375376
},
376-
wantFinished: 1,
377-
wantCacheCount: 1,
377+
wantFinished: 1,
378+
wantCacheCount: 1,
379+
workingDirectory: tempDir,
378380
},
379381
}
380382

@@ -426,10 +428,11 @@ func TestExecutor_Integration(t *testing.T) {
426428
Logger: mock.LogNoOpManager{},
427429
EnsureImage: imageMapEnsurer(images),
428430

429-
TempDir: testTempDir,
430-
Parallelism: runtime.GOMAXPROCS(parallelism),
431-
Timeout: tc.executorTimeout,
432-
FailFast: tc.failFast,
431+
TempDir: testTempDir,
432+
Parallelism: runtime.GOMAXPROCS(parallelism),
433+
Timeout: tc.executorTimeout,
434+
FailFast: tc.failFast,
435+
WorkingDirectory: tc.workingDirectory,
433436
}
434437

435438
if opts.Timeout == 0 {

internal/batches/executor/run_steps.go

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -610,26 +610,38 @@ func createCidFile(ctx context.Context, tempDir string, repoSlug string) (string
610610
}
611611

612612
func getAbsoluteMountPath(batchSpecDir string, mountPath string) (string, error) {
613+
// Use OpenRoot to prevent path traversal and symlink attacks via mount paths
614+
root, err := os.OpenRoot(batchSpecDir)
615+
if err != nil {
616+
return "", errors.Wrap(err, "opening batch spec directory")
617+
}
618+
defer root.Close()
619+
613620
p := mountPath
614-
if !filepath.IsAbs(p) {
615-
// Try to build the absolute path since Docker will only mount absolute paths
616-
p = filepath.Join(batchSpecDir, p)
621+
if filepath.IsAbs(p) {
622+
rel, err := filepath.Rel(batchSpecDir, p)
623+
if err != nil || strings.HasPrefix(rel, "..") {
624+
return "", errors.Newf("mount path %s is not in the same directory or subdirectory as the batch spec", mountPath)
625+
}
626+
p = rel
617627
}
618-
pathInfo, err := os.Stat(p)
628+
629+
pathInfo, err := root.Stat(p)
619630
if os.IsNotExist(err) {
620-
return "", errors.Newf("mount path %s does not exist", p)
631+
return "", errors.Newf("mount path %s does not exist", mountPath)
621632
} else if err != nil {
622-
return "", errors.Wrap(err, "mount path validation")
623-
}
624-
if !strings.HasPrefix(p, batchSpecDir) {
625633
return "", errors.Newf("mount path %s is not in the same directory or subdirectory as the batch spec", mountPath)
626634
}
635+
636+
// Build the absolute path for Docker bind mount.
637+
absPath := filepath.Join(batchSpecDir, p)
638+
627639
// Mounting a directory on Docker must end with the separator. So, append the file separator to make
628640
// users' lives easier.
629-
if pathInfo.IsDir() && !strings.HasSuffix(p, string(filepath.Separator)) {
630-
p += string(filepath.Separator)
641+
if pathInfo.IsDir() && !strings.HasSuffix(absPath, string(filepath.Separator)) {
642+
absPath += string(filepath.Separator)
631643
}
632-
return p, nil
644+
return absPath, nil
633645
}
634646

635647
type stepFailedErr struct {

internal/batches/service/remote.go

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -129,31 +129,43 @@ func (svc *Service) UploadBatchSpecWorkspaceFiles(ctx context.Context, workingDi
129129
}
130130

131131
func getFilePaths(workingDir, filePath string) ([]string, error) {
132+
root, err := os.OpenRoot(workingDir)
133+
if err != nil {
134+
return nil, errors.Wrap(err, "opening working directory")
135+
}
136+
defer root.Close()
137+
return getFilePathsInRoot(root, filePath)
138+
}
139+
140+
func getFilePathsInRoot(root *os.Root, filePath string) ([]string, error) {
141+
// Clean the path to resolve any ".." segments before accessing the root.
142+
filePath = filepath.Clean(filePath)
143+
132144
var filePaths []string
133-
actualFilePath := filepath.Join(workingDir, filePath)
134-
info, err := os.Stat(actualFilePath)
145+
info, err := root.Stat(filePath)
135146
if err != nil {
136147
return nil, err
137148
}
138149

139150
if info.IsDir() {
140-
dir, err := os.ReadDir(actualFilePath)
151+
dir, err := root.Open(filePath)
152+
if err != nil {
153+
return nil, err
154+
}
155+
entries, err := dir.ReadDir(-1)
156+
dir.Close()
141157
if err != nil {
142158
return nil, err
143159
}
144-
for _, dirEntry := range dir {
145-
paths, err := getFilePaths(workingDir, filepath.Join(filePath, dirEntry.Name()))
160+
for _, dirEntry := range entries {
161+
paths, err := getFilePathsInRoot(root, filepath.Join(filePath, dirEntry.Name()))
146162
if err != nil {
147163
return nil, err
148164
}
149165
filePaths = append(filePaths, paths...)
150166
}
151167
} else {
152-
relPath, err := filepath.Rel(workingDir, actualFilePath)
153-
if err != nil {
154-
return nil, err
155-
}
156-
filePaths = append(filePaths, relPath)
168+
filePaths = append(filePaths, filePath)
157169
}
158170
return filePaths, nil
159171
}
@@ -201,7 +213,13 @@ func (svc *Service) uploadFile(ctx context.Context, workingDir, filePath, batchS
201213
const maxFileSize = 10 << 20 // 10MB
202214

203215
func createFormFile(w *multipart.Writer, workingDir string, mountPath string) error {
204-
f, err := os.Open(filepath.Join(workingDir, mountPath))
216+
root, err := os.OpenRoot(workingDir)
217+
if err != nil {
218+
return errors.Wrap(err, "opening working directory")
219+
}
220+
defer root.Close()
221+
222+
f, err := root.Open(mountPath)
205223
if err != nil {
206224
return err
207225
}

internal/batches/service/service.go

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -486,19 +486,39 @@ func (svc *Service) ParseBatchSpec(dir string, data []byte) (*batcheslib.BatchSp
486486
}
487487

488488
func validateMount(batchSpecDir string, spec *batcheslib.BatchSpec) error {
489+
// Check if any step has mounts before opening the root directory.
490+
hasMounts := false
491+
for _, step := range spec.Steps {
492+
if len(step.Mount) > 0 {
493+
hasMounts = true
494+
break
495+
}
496+
}
497+
if !hasMounts {
498+
return nil
499+
}
500+
501+
root, err := os.OpenRoot(batchSpecDir)
502+
if err != nil {
503+
return errors.Wrap(err, "opening batch spec directory")
504+
}
505+
defer root.Close()
506+
489507
for i, step := range spec.Steps {
490508
for _, mount := range step.Mount {
491-
if !filepath.IsAbs(mount.Path) {
492-
// Try to build the absolute path since Docker will only mount absolute paths
493-
mount.Path = filepath.Join(batchSpecDir, mount.Path)
509+
mountPath := mount.Path
510+
if filepath.IsAbs(mountPath) {
511+
// Convert absolute paths to relative paths within the root.
512+
rel, err := filepath.Rel(batchSpecDir, mountPath)
513+
if err != nil || strings.HasPrefix(rel, "..") {
514+
return errors.Newf("step %d mount path is not in the same directory or subdirectory as the batch spec", i+1)
515+
}
516+
mountPath = rel
494517
}
495-
_, err := os.Stat(mount.Path)
518+
_, err := root.Stat(mountPath)
496519
if os.IsNotExist(err) {
497520
return errors.Newf("step %d mount path %s does not exist", i+1, mount.Path)
498521
} else if err != nil {
499-
return errors.Wrapf(err, "step %d mount path validation", i+1)
500-
}
501-
if !strings.HasPrefix(mount.Path, batchSpecDir) {
502522
return errors.Newf("step %d mount path is not in the same directory or subdirectory as the batch spec", i+1)
503523
}
504524
}

internal/batches/service/service_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,17 @@ func TestService_ParseBatchSpec(t *testing.T) {
230230
_, err = os.Create(filepath.Join(tempDir, "another.sh"))
231231
require.NoError(t, err)
232232

233+
// Create a sibling directory whose name shares a prefix with tempDir (e.g. /tmp/specdir-123 vs /tmp/specdir-123-outside)
234+
prefixBypassDir := tempDir + "-outside"
235+
require.NoError(t, os.MkdirAll(prefixBypassDir, 0o755))
236+
t.Cleanup(func() { os.RemoveAll(prefixBypassDir) })
237+
secretFile := filepath.Join(prefixBypassDir, "secret.txt")
238+
require.NoError(t, os.WriteFile(secretFile, []byte("SECRET"), 0o644))
239+
240+
// Create a symlink inside tempDir that points outside it
241+
symlinkPath := filepath.Join(tempDir, "leak")
242+
require.NoError(t, os.Symlink(secretFile, symlinkPath))
243+
233244
tests := []struct {
234245
name string
235246
batchSpecDir string
@@ -541,6 +552,69 @@ changesetTemplate:
541552
`, tempOutsideDir),
542553
expectedErr: errors.New("handling mount: step 1 mount path is not in the same directory or subdirectory as the batch spec"),
543554
},
555+
{
556+
name: "mount path prefix confusion bypass",
557+
batchSpecDir: tempDir,
558+
rawSpec: fmt.Sprintf(`
559+
name: test-spec
560+
description: A test spec
561+
steps:
562+
- run: cat /tmp/mounted
563+
container: alpine:3
564+
mount:
565+
- path: %s
566+
mountpoint: /tmp/mounted
567+
changesetTemplate:
568+
title: Test Mount
569+
body: Test a mounted path
570+
branch: test
571+
commit:
572+
message: Test
573+
`, secretFile),
574+
expectedErr: errors.New("handling mount: step 1 mount path is not in the same directory or subdirectory as the batch spec"),
575+
},
576+
{
577+
name: "mount path symlink escape",
578+
batchSpecDir: tempDir,
579+
rawSpec: `
580+
name: test-spec
581+
description: A test spec
582+
steps:
583+
- run: cat /tmp/mounted
584+
container: alpine:3
585+
mount:
586+
- path: ./leak
587+
mountpoint: /tmp/mounted
588+
changesetTemplate:
589+
title: Test Mount
590+
body: Test a mounted path
591+
branch: test
592+
commit:
593+
message: Test
594+
`,
595+
expectedErr: errors.New("handling mount: step 1 mount path is not in the same directory or subdirectory as the batch spec"),
596+
},
597+
{
598+
name: "mount path dot-dot traversal",
599+
batchSpecDir: tempDir,
600+
rawSpec: `
601+
name: test-spec
602+
description: A test spec
603+
steps:
604+
- run: cat /tmp/mounted
605+
container: alpine:3
606+
mount:
607+
- path: ../../../etc/passwd
608+
mountpoint: /tmp/mounted
609+
changesetTemplate:
610+
title: Test Mount
611+
body: Test a mounted path
612+
branch: test
613+
commit:
614+
message: Test
615+
`,
616+
expectedErr: errors.New("handling mount: step 1 mount path is not in the same directory or subdirectory as the batch spec"),
617+
},
544618
}
545619
for _, test := range tests {
546620
t.Run(test.name, func(t *testing.T) {

0 commit comments

Comments
 (0)