Skip to content

Commit 002924b

Browse files
committed
fix/batches: prevent command injection from repository paths
1 parent bc8db51 commit 002924b

2 files changed

Lines changed: 129 additions & 80 deletions

File tree

‎internal/batches/workspace/volume_workspace.go‎

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
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"
1114

1215
"github.com/sourcegraph/sourcegraph/lib/errors"
@@ -180,55 +183,105 @@ func (wc *dockerVolumeWorkspaceCreator) copyFilesIntoVolumes(ctx context.Context
180183
if len(files) == 0 {
181184
return nil
182185
}
183-
const copyScript = `while test "$#" -gt 0; do cp "$1" "$2" || exit; shift 2; done`
186+
187+
archive, err := wc.archiveAdditionalFiles(files)
188+
if err != nil {
189+
return err
190+
}
191+
defer os.Remove(archive)
192+
archiveMount, err := docker.BindMount(archive, "/tmp/additional-files.tar", true)
193+
if err != nil {
194+
return errors.Wrap(err, "creating additional files archive mount")
195+
}
184196

185197
opts := append([]string{
186198
"run",
187199
"--rm",
188200
"--init",
189201
"--workdir", "/work",
202+
"--mount", archiveMount,
190203
}, w.dockerRunOptsWithUser(w.uidGid, "/work")...)
191204

192-
// We sort these so our tests don't break. Sorry.
205+
opts = append(
206+
opts,
207+
DockerVolumeWorkspaceImage,
208+
"tar", "-xf", "/tmp/additional-files.tar", "-C", "/work",
209+
)
210+
211+
if out, err := exec.CommandContext(ctx, "docker", opts...).CombinedOutput(); err != nil {
212+
return errors.Wrapf(err, "additional files output:\n\n%s\n\n", string(out))
213+
}
214+
return nil
215+
}
216+
217+
func (wc *dockerVolumeWorkspaceCreator) archiveAdditionalFiles(files map[string]string) (archivePath string, err error) {
218+
f, err := os.CreateTemp(wc.tempDir, "src-additional-files-*.tar")
219+
if err != nil {
220+
return "", errors.Wrap(err, "creating additional files archive")
221+
}
222+
archivePath = f.Name()
223+
defer func() {
224+
if err != nil {
225+
f.Close()
226+
os.Remove(archivePath)
227+
}
228+
}()
229+
230+
tw := tar.NewWriter(f)
193231
var names []string
194232
for name := range files {
195233
names = append(names, name)
196234
}
197235
sort.Strings(names)
198236

199-
var copyArgs []string
200-
for i, name := range names {
237+
for _, name := range names {
201238
if err := validateWorkspaceFileName(name); err != nil {
202-
return err
239+
return "", err
203240
}
204-
localPath := files[name]
205-
// Names originate from the Sourcegraph instance. Keep them out of both
206-
// Docker's comma-delimited mount grammar and the shell program.
207-
mountTarget := fmt.Sprintf("/tmp/src-additional-file-%d", i)
208-
mount, err := docker.BindMount(localPath, mountTarget, true)
241+
if name == "" || path.Clean(name) != name {
242+
return "", errors.Errorf("invalid additional file path %q", name)
243+
}
244+
245+
file, err := os.Open(files[name])
246+
if err != nil {
247+
return "", errors.Wrapf(err, "opening additional file %q", name)
248+
}
249+
info, err := file.Stat()
209250
if err != nil {
210-
return errors.Wrap(err, "creating additional file mount")
251+
file.Close()
252+
return "", errors.Wrapf(err, "stating additional file %q", name)
253+
}
254+
if !info.Mode().IsRegular() {
255+
file.Close()
256+
return "", errors.Errorf("additional file %q is not a regular file", name)
211257
}
212-
opts = append(opts, []string{
213-
"--mount", mount,
214-
}...)
215258

216-
copyArgs = append(copyArgs, mountTarget, "/work/"+name)
259+
header, err := tar.FileInfoHeader(info, "")
260+
if err != nil {
261+
file.Close()
262+
return "", errors.Wrapf(err, "creating archive header for additional file %q", name)
263+
}
264+
header.Name = name
265+
if err := tw.WriteHeader(header); err != nil {
266+
file.Close()
267+
return "", errors.Wrapf(err, "writing archive header for additional file %q", name)
268+
}
269+
if _, err := io.Copy(tw, file); err != nil {
270+
file.Close()
271+
return "", errors.Wrapf(err, "archiving additional file %q", name)
272+
}
273+
if err := file.Close(); err != nil {
274+
return "", errors.Wrapf(err, "closing additional file %q", name)
275+
}
217276
}
218277

219-
opts = append(
220-
opts,
221-
DockerVolumeWorkspaceImage,
222-
"sh", "-c",
223-
copyScript,
224-
"copy-additional-files",
225-
)
226-
opts = append(opts, copyArgs...)
227-
228-
if out, err := exec.CommandContext(ctx, "docker", opts...).CombinedOutput(); err != nil {
229-
return errors.Wrapf(err, "unzip output:\n\n%s\n\n", string(out))
278+
if err := tw.Close(); err != nil {
279+
return "", errors.Wrap(err, "closing additional files archive")
230280
}
231-
return nil
281+
if err := f.Close(); err != nil {
282+
return "", errors.Wrap(err, "closing additional files archive file")
283+
}
284+
return archivePath, nil
232285
}
233286

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

‎internal/batches/workspace/volume_workspace_test.go‎

Lines changed: 49 additions & 53 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,15 +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/src-additional-file-0,ro",
342-
"--mount", "type=bind,source="+archiveWithAdditionalFiles.mockAdditionalFilePaths["another-file"]+",target=/tmp/src-additional-file-1,ro",
343341
DockerVolumeWorkspaceImage,
344-
"sh", "-c", `while test "$#" -gt 0; do cp "$1" "$2" || exit; shift 2; done`,
345-
"copy-additional-files",
346-
"/tmp/src-additional-file-0", "/work/.gitignore",
347-
"/tmp/src-additional-file-1", "/work/another-file",
342+
"tar", "-xf", "/tmp/additional-files.tar", "-C", "/work",
348343
),
349344
expect.NewGlob(
350345
expect.Success,
@@ -388,53 +383,54 @@ func TestVolumeWorkspaceCreator(t *testing.T) {
388383
}
389384
}
390385

391-
func TestCopyFilesIntoVolumesDoesNotInterpolateNames(t *testing.T) {
392-
const maliciousName = "x,ro,type=bind,source=/var/run/docker.sock,target=/h1sock;touch /work/injected;/.gitignore"
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+
}
393392

394-
expect.Commands(
395-
t,
396-
expect.NewGlob(
397-
expect.Success,
398-
"docker", "run", "--rm", "--init", "--workdir", "/work",
399-
"--user", "0:0",
400-
"--mount", "type=volume,source="+volumeID+",target=/work",
401-
"--mount", "type=bind,source=/tmp/additional-file,target=/tmp/src-additional-file-0,ro",
402-
DockerVolumeWorkspaceImage,
403-
"sh", "-c", `while test "$#" -gt 0; do cp "$1" "$2" || exit; shift 2; done`,
404-
"copy-additional-files",
405-
"/tmp/src-additional-file-0", "/work/"+maliciousName,
406-
),
407-
)
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)
408399

409-
wc := &dockerVolumeWorkspaceCreator{}
410-
w := &dockerVolumeWorkspace{volume: volumeID}
411-
err := wc.copyFilesIntoVolumes(context.Background(), w, map[string]string{
412-
maliciousName: "/tmp/additional-file",
413-
})
400+
f, err := os.Open(archive)
414401
if err != nil {
415-
t.Fatalf("unexpected error: %v", err)
402+
t.Fatal(err)
416403
}
417-
}
404+
defer f.Close()
418405

419-
func TestCopyFilesIntoVolumesRejectsUnsafePaths(t *testing.T) {
420-
tests := map[string]map[string]string{
421-
"workspace traversal": {
422-
"../etc/.gitignore": "/tmp/additional-file",
423-
},
424-
"mount source injection": {
425-
".gitignore": "/tmp/additional-file,source=/etc",
426-
},
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)
427417
}
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+
}
428425

429-
for name, files := range tests {
430-
t.Run(name, func(t *testing.T) {
431-
expect.Commands(t)
432-
wc := &dockerVolumeWorkspaceCreator{}
433-
w := &dockerVolumeWorkspace{volume: volumeID}
434-
if err := wc.copyFilesIntoVolumes(context.Background(), w, files); err == nil {
435-
t.Fatal("expected unsafe path to be rejected")
436-
}
437-
})
426+
func TestCopyFilesIntoVolumesRejectsWorkspaceTraversal(t *testing.T) {
427+
expect.Commands(t)
428+
wc := &dockerVolumeWorkspaceCreator{}
429+
w := &dockerVolumeWorkspace{volume: volumeID}
430+
if err := wc.copyFilesIntoVolumes(context.Background(), w, map[string]string{
431+
"../etc/.gitignore": "/tmp/additional-file",
432+
}); err == nil {
433+
t.Fatal("expected workspace traversal to be rejected")
438434
}
439435
}
440436

0 commit comments

Comments
 (0)