Skip to content

Commit 5fb1a59

Browse files
committed
fix/batches: prevent command injection from repository paths
1 parent 695e69c commit 5fb1a59

2 files changed

Lines changed: 124 additions & 28 deletions

File tree

‎internal/batches/workspace/volume_workspace.go‎

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
package workspace
22

33
import (
4+
"archive/tar"
45
"bytes"
56
"context"
67
"crypto/rand"
78
"encoding/hex"
89
"fmt"
10+
"io"
911
"os"
12+
"path"
1013
"sort"
11-
"strings"
1214

1315
"github.com/sourcegraph/sourcegraph/lib/errors"
1416

@@ -178,41 +180,97 @@ func (wc *dockerVolumeWorkspaceCreator) copyFilesIntoVolumes(ctx context.Context
178180
return nil
179181
}
180182

183+
archive, err := wc.archiveAdditionalFiles(files)
184+
if err != nil {
185+
return err
186+
}
187+
defer os.Remove(archive)
188+
181189
opts := append([]string{
182190
"run",
183191
"--rm",
184192
"--init",
185193
"--workdir", "/work",
194+
"--mount", "type=bind,source=" + archive + ",target=/tmp/additional-files.tar,ro",
186195
}, w.dockerRunOptsWithUser(w.uidGid, "/work")...)
187196

188-
// We sort these so our tests don't break. Sorry.
197+
opts = append(
198+
opts,
199+
DockerVolumeWorkspaceImage,
200+
"tar", "-xf", "/tmp/additional-files.tar", "-C", "/work",
201+
)
202+
203+
if out, err := exec.CommandContext(ctx, "docker", opts...).CombinedOutput(); err != nil {
204+
return errors.Wrapf(err, "additional files output:\n\n%s\n\n", string(out))
205+
}
206+
return nil
207+
}
208+
209+
func (wc *dockerVolumeWorkspaceCreator) archiveAdditionalFiles(files map[string]string) (archivePath string, err error) {
210+
f, err := os.CreateTemp(wc.tempDir, "src-additional-files-*.tar")
211+
if err != nil {
212+
return "", errors.Wrap(err, "creating additional files archive")
213+
}
214+
archivePath = f.Name()
215+
defer func() {
216+
if err != nil {
217+
f.Close()
218+
os.Remove(archivePath)
219+
}
220+
}()
221+
222+
tw := tar.NewWriter(f)
189223
var names []string
190224
for name := range files {
191225
names = append(names, name)
192226
}
193227
sort.Strings(names)
194228

195-
var copyCmds []string
196229
for _, name := range names {
197-
localPath := files[name]
198-
opts = append(opts, []string{
199-
"--mount", "type=bind,source=" + localPath + ",target=/tmp/" + name + ",ro",
200-
}...)
230+
if name == "" || path.IsAbs(name) || path.Clean(name) != name || name == ".." || len(name) >= 3 && name[:3] == "../" {
231+
return "", errors.Errorf("invalid additional file path %q", name)
232+
}
201233

202-
copyCmds = append(copyCmds, "cp /tmp/"+name+" /work/"+name)
203-
}
234+
file, err := os.Open(files[name])
235+
if err != nil {
236+
return "", errors.Wrapf(err, "opening additional file %q", name)
237+
}
238+
info, err := file.Stat()
239+
if err != nil {
240+
file.Close()
241+
return "", errors.Wrapf(err, "stating additional file %q", name)
242+
}
243+
if !info.Mode().IsRegular() {
244+
file.Close()
245+
return "", errors.Errorf("additional file %q is not a regular file", name)
246+
}
204247

205-
opts = append(
206-
opts,
207-
DockerVolumeWorkspaceImage,
208-
"sh", "-c",
209-
strings.Join(copyCmds, " && ")+";",
210-
)
248+
header, err := tar.FileInfoHeader(info, "")
249+
if err != nil {
250+
file.Close()
251+
return "", errors.Wrapf(err, "creating archive header for additional file %q", name)
252+
}
253+
header.Name = name
254+
if err := tw.WriteHeader(header); err != nil {
255+
file.Close()
256+
return "", errors.Wrapf(err, "writing archive header for additional file %q", name)
257+
}
258+
if _, err := io.Copy(tw, file); err != nil {
259+
file.Close()
260+
return "", errors.Wrapf(err, "archiving additional file %q", name)
261+
}
262+
if err := file.Close(); err != nil {
263+
return "", errors.Wrapf(err, "closing additional file %q", name)
264+
}
265+
}
211266

212-
if out, err := exec.CommandContext(ctx, "docker", opts...).CombinedOutput(); err != nil {
213-
return errors.Wrapf(err, "unzip output:\n\n%s\n\n", string(out))
267+
if err := tw.Close(); err != nil {
268+
return "", errors.Wrap(err, "closing additional files archive")
214269
}
215-
return nil
270+
if err := f.Close(); err != nil {
271+
return "", errors.Wrap(err, "closing additional files archive file")
272+
}
273+
return archivePath, nil
216274
}
217275

218276
// dockerVolumeWorkspace workspaces are placed on Docker volumes (surprise!),

‎internal/batches/workspace/volume_workspace_test.go‎

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package workspace
22

33
import (
4+
"archive/tar"
45
"context"
6+
"io"
57
"os"
68
"path/filepath"
79
"strings"
@@ -39,13 +41,10 @@ func TestVolumeWorkspaceCreator(t *testing.T) {
3941
mockAdditionalFilePaths: map[string]string{},
4042
}
4143
for _, name := range []string{".gitignore", "another-file"} {
42-
// Since we don't read the files and mock the Docker commands,
43-
// we don't need to create them.
44-
path := filepath.Join(os.TempDir(), "additional-file"+name)
45-
// Instead we create a real-looking path that we sanitize so
46-
// it doesn't trip up the globbing expecations below:
47-
path = strings.ReplaceAll(path, string(os.PathSeparator), "-")
48-
44+
path := filepath.Join(t.TempDir(), "additional-file"+name)
45+
if err := os.WriteFile(path, []byte(name), 0600); err != nil {
46+
t.Fatal(err)
47+
}
4948
archiveWithAdditionalFiles.mockAdditionalFilePaths[name] = path
5049
}
5150

@@ -336,12 +335,11 @@ func TestVolumeWorkspaceCreator(t *testing.T) {
336335
expect.Success,
337336
"docker", "run", "--rm", "--init",
338337
"--workdir", "/work",
338+
"--mount", "type=bind,source=*,target=/tmp/additional-files.tar,ro",
339339
"--user", "0:0",
340340
"--mount", "type=volume,source="+volumeID+",target=/work",
341-
"--mount", "type=bind,source="+archiveWithAdditionalFiles.mockAdditionalFilePaths[".gitignore"]+",target=/tmp/.gitignore,ro",
342-
"--mount", "type=bind,source="+archiveWithAdditionalFiles.mockAdditionalFilePaths["another-file"]+",target=/tmp/another-file,ro",
343341
DockerVolumeWorkspaceImage,
344-
"sh", "-c", "cp /tmp/.gitignore /work/.gitignore && cp /tmp/another-file /work/another-file;",
342+
"tar", "-xf", "/tmp/additional-files.tar", "-C", "/work",
345343
),
346344
expect.NewGlob(
347345
expect.Success,
@@ -385,6 +383,46 @@ func TestVolumeWorkspaceCreator(t *testing.T) {
385383
}
386384
}
387385

386+
func TestArchiveAdditionalFilesTreatsRepositoryPathsAsData(t *testing.T) {
387+
const maliciousName = "x;touch${IFS}/tmp/pwned,source=.,target=/x/.gitignore"
388+
source := filepath.Join(t.TempDir(), "additional-file")
389+
if err := os.WriteFile(source, []byte("contents"), 0600); err != nil {
390+
t.Fatal(err)
391+
}
392+
393+
wc := &dockerVolumeWorkspaceCreator{tempDir: t.TempDir()}
394+
archive, err := wc.archiveAdditionalFiles(map[string]string{maliciousName: source})
395+
if err != nil {
396+
t.Fatal(err)
397+
}
398+
defer os.Remove(archive)
399+
400+
f, err := os.Open(archive)
401+
if err != nil {
402+
t.Fatal(err)
403+
}
404+
defer f.Close()
405+
406+
r := tar.NewReader(f)
407+
header, err := r.Next()
408+
if err != nil {
409+
t.Fatal(err)
410+
}
411+
if header.Name != maliciousName {
412+
t.Fatalf("unexpected archived path: have=%q want=%q", header.Name, maliciousName)
413+
}
414+
contents, err := io.ReadAll(r)
415+
if err != nil {
416+
t.Fatal(err)
417+
}
418+
if string(contents) != "contents" {
419+
t.Fatalf("unexpected archived contents: %q", contents)
420+
}
421+
if _, err := r.Next(); err != io.EOF {
422+
t.Fatalf("unexpected second archive entry: %v", err)
423+
}
424+
}
425+
388426
func TestVolumeWorkspace_Close(t *testing.T) {
389427
ctx := context.Background()
390428
w := &dockerVolumeWorkspace{volume: volumeID}

0 commit comments

Comments
 (0)