Skip to content

Commit bc8db51

Browse files
authored
fix/batches: prevent workspace path injection (#1372)
1 parent ed0828d commit bc8db51

8 files changed

Lines changed: 241 additions & 30 deletions

File tree

internal/batches/docker/mount.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package docker
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/sourcegraph/sourcegraph/lib/errors"
8+
)
9+
10+
// BindMount returns a Docker bind mount specification after checking that its
11+
// values cannot inject fields into Docker's comma-delimited mount grammar.
12+
func BindMount(source, target string, readOnly bool) (string, error) {
13+
for name, value := range map[string]string{"source": source, "target": target} {
14+
if value == "" || strings.ContainsAny(value, ",\r\n\x00") {
15+
return "", errors.Newf("invalid Docker mount %s %q", name, value)
16+
}
17+
}
18+
19+
mount := fmt.Sprintf("type=bind,source=%s,target=%s", source, target)
20+
if readOnly {
21+
mount += ",ro"
22+
}
23+
return mount, nil
24+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package docker
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestBindMount(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
source string
13+
target string
14+
readOnly bool
15+
want string
16+
wantErr bool
17+
}{
18+
{
19+
name: "writable",
20+
source: "/tmp/workspace",
21+
target: "/work",
22+
want: "type=bind,source=/tmp/workspace,target=/work",
23+
},
24+
{
25+
name: "read-only",
26+
source: "/tmp/archive",
27+
target: "/tmp/archive",
28+
readOnly: true,
29+
want: "type=bind,source=/tmp/archive,target=/tmp/archive,ro",
30+
},
31+
{
32+
name: "source injection",
33+
source: "/tmp/archive,source=/etc",
34+
target: "/tmp/archive",
35+
wantErr: true,
36+
},
37+
{
38+
name: "target injection",
39+
source: "/tmp/archive",
40+
target: "/tmp/archive,source=/etc",
41+
wantErr: true,
42+
},
43+
}
44+
45+
for _, test := range tests {
46+
t.Run(test.name, func(t *testing.T) {
47+
got, err := BindMount(test.source, test.target, test.readOnly)
48+
if test.wantErr {
49+
require.Error(t, err)
50+
return
51+
}
52+
require.NoError(t, err)
53+
require.Equal(t, test.want, got)
54+
})
55+
}
56+
}

internal/batches/executor/run_steps.go

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/sourcegraph/sourcegraph/lib/batches/template"
2222
"github.com/sourcegraph/sourcegraph/lib/errors"
2323

24+
"github.com/sourcegraph/src-cli/internal/batches/docker"
2425
"github.com/sourcegraph/src-cli/internal/batches/log"
2526
"github.com/sourcegraph/src-cli/internal/batches/repozip"
2627
"github.com/sourcegraph/src-cli/internal/batches/util"
@@ -354,7 +355,7 @@ func executeSingleStep(
354355
if err := validateContainerTempPath(containerTemp); err != nil {
355356
return bytes.Buffer{}, bytes.Buffer{}, errors.Wrap(err, "validating run script target")
356357
}
357-
runScriptMount, err := dockerBindMount(runScriptFile, containerTemp)
358+
runScriptMount, err := docker.BindMount(runScriptFile, containerTemp, true)
358359
if err != nil {
359360
return bytes.Buffer{}, bytes.Buffer{}, errors.Wrap(err, "creating run script mount")
360361
}
@@ -373,7 +374,7 @@ func executeSingleStep(
373374
}
374375

375376
for target, source := range filesToMount {
376-
mountArg, err := dockerBindMount(source.Name(), target)
377+
mountArg, err := docker.BindMount(source.Name(), target, true)
377378
if err != nil {
378379
return bytes.Buffer{}, bytes.Buffer{}, errors.Wrap(err, "creating files mount")
379380
}
@@ -386,7 +387,7 @@ func executeSingleStep(
386387
if err != nil {
387388
return bytes.Buffer{}, bytes.Buffer{}, err
388389
}
389-
mountArg, err := dockerBindMount(workspaceFilePath, mount.Mountpoint)
390+
mountArg, err := docker.BindMount(workspaceFilePath, mount.Mountpoint, true)
390391
if err != nil {
391392
return bytes.Buffer{}, bytes.Buffer{}, errors.Wrap(err, "creating host mount")
392393
}
@@ -581,15 +582,6 @@ func validateContainerTempPath(tempfile string) error {
581582
return nil
582583
}
583584

584-
func dockerBindMount(source, target string) (string, error) {
585-
for name, value := range map[string]string{"source": source, "target": target} {
586-
if value == "" || strings.ContainsAny(value, ",\r\n\x00") {
587-
return "", errors.Newf("invalid Docker mount %s %q", name, value)
588-
}
589-
}
590-
return fmt.Sprintf("type=bind,source=%s,target=%s,ro", source, target), nil
591-
}
592-
593585
// createFilesToMount creates temporary files with the contents of Step.Files
594586
// that are to be mounted into the container that executes the step.
595587
func createFilesToMount(tempDir string, step batcheslib.Step, stepContext *template.StepContext) (map[string]*os.File, func(), error) {

internal/batches/executor/run_steps_test.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,6 @@ func TestProbeImageForShellRejectsMountInjection(t *testing.T) {
8888
require.Contains(t, err.Error(), "mktemp returned invalid path")
8989
}
9090

91-
func TestDockerBindMountRejectsMountGrammar(t *testing.T) {
92-
_, err := dockerBindMount("/tmp/script", "/tmp/x,source=/var/run/docker.sock")
93-
require.Error(t, err)
94-
}
95-
9691
func TestCreateFilesToMount_RejectsCommaInTargetPath(t *testing.T) {
9792
step := batcheslib.Step{
9893
Files: map[string]string{

internal/batches/workspace/bind_workspace.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
batcheslib "github.com/sourcegraph/sourcegraph/lib/batches"
1414
"github.com/sourcegraph/sourcegraph/lib/errors"
1515

16+
"github.com/sourcegraph/src-cli/internal/batches/docker"
1617
"github.com/sourcegraph/src-cli/internal/batches/graphql"
1718
"github.com/sourcegraph/src-cli/internal/batches/repozip"
1819
"github.com/sourcegraph/src-cli/internal/batches/util"
@@ -77,6 +78,10 @@ func (wc *dockerBindWorkspaceCreator) unzipToWorkspace(ctx context.Context, repo
7778

7879
func (wc *dockerBindWorkspaceCreator) copyToWorkspace(ctx context.Context, w *dockerBindWorkspace, files map[string]string) error {
7980
for name, src := range files {
81+
if err := validateWorkspaceFileName(name); err != nil {
82+
return err
83+
}
84+
8085
srcStat, err := os.Stat(src)
8186
if err != nil {
8287
return err
@@ -91,7 +96,7 @@ func (wc *dockerBindWorkspaceCreator) copyToWorkspace(ctx context.Context, w *do
9196
return err
9297
}
9398

94-
destPath := path.Join(w.dir, name)
99+
destPath := filepath.Join(w.dir, filepath.FromSlash(name))
95100

96101
destFile, err := prepareCopyDestinationFile(srcStat, destPath)
97102
if err != nil {
@@ -136,12 +141,29 @@ func (w *dockerBindWorkspace) Close(ctx context.Context) error {
136141
}
137142

138143
func (w *dockerBindWorkspace) DockerRunOpts(ctx context.Context, target string) ([]string, error) {
144+
mount, err := docker.BindMount(w.dir, target, false)
145+
if err != nil {
146+
return nil, err
147+
}
139148
return []string{
140149
"--mount",
141-
fmt.Sprintf("type=bind,source=%s,target=%s", w.dir, target),
150+
mount,
142151
}, nil
143152
}
144153

154+
func validateWorkspaceFileName(name string) error {
155+
clean := path.Clean(name)
156+
if path.IsAbs(name) || clean == ".." || strings.HasPrefix(clean, "../") {
157+
return errors.Newf("workspace file path %q is outside the workspace", name)
158+
}
159+
160+
native := filepath.Clean(filepath.FromSlash(name))
161+
if filepath.IsAbs(native) || filepath.VolumeName(native) != "" || native == ".." || strings.HasPrefix(native, ".."+string(os.PathSeparator)) {
162+
return errors.Newf("workspace file path %q is outside the workspace", name)
163+
}
164+
return nil
165+
}
166+
145167
func (w *dockerBindWorkspace) WorkDir() *string { return &w.dir }
146168

147169
func (w *dockerBindWorkspace) Diff(ctx context.Context) ([]byte, error) {

internal/batches/workspace/bind_workspace_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,45 @@ func TestDockerBindWorkspaceCreator_Create(t *testing.T) {
143143
})
144144
}
145145

146+
func TestCopyToWorkspaceRejectsPathTraversal(t *testing.T) {
147+
root := t.TempDir()
148+
workspaceDir := filepath.Join(root, "workspace")
149+
if err := os.Mkdir(workspaceDir, 0755); err != nil {
150+
t.Fatal(err)
151+
}
152+
victimDir := filepath.Join(root, "victim")
153+
if err := os.Mkdir(victimDir, 0700); err != nil {
154+
t.Fatal(err)
155+
}
156+
before, err := os.Stat(victimDir)
157+
if err != nil {
158+
t.Fatal(err)
159+
}
160+
source := filepath.Join(root, "source")
161+
if err := os.WriteFile(source, []byte("attacker content"), 0600); err != nil {
162+
t.Fatal(err)
163+
}
164+
165+
creator := &dockerBindWorkspaceCreator{}
166+
workspace := &dockerBindWorkspace{dir: workspaceDir}
167+
err = creator.copyToWorkspace(context.Background(), workspace, map[string]string{
168+
"../victim/.gitignore": source,
169+
})
170+
if err == nil || !strings.Contains(err.Error(), "outside the workspace") {
171+
t.Fatalf("expected path traversal error, got %v", err)
172+
}
173+
if _, err := os.Stat(filepath.Join(victimDir, ".gitignore")); !os.IsNotExist(err) {
174+
t.Fatalf("file was written outside the workspace: %v", err)
175+
}
176+
info, err := os.Stat(victimDir)
177+
if err != nil {
178+
t.Fatal(err)
179+
}
180+
if got, want := info.Mode().Perm(), before.Mode().Perm(); got != want {
181+
t.Fatalf("outside directory permissions changed: got %o, want %o", got, want)
182+
}
183+
}
184+
146185
func TestPrepareGitRepoRemovesUntrustedGitMetadata(t *testing.T) {
147186
dir := t.TempDir()
148187
dotGit := filepath.Join(dir, ".git")

internal/batches/workspace/volume_workspace.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"fmt"
99
"os"
1010
"sort"
11-
"strings"
1211

1312
"github.com/sourcegraph/sourcegraph/lib/errors"
1413

@@ -112,6 +111,10 @@ git commit --quiet --all --allow-empty -m src-action-exec
112111
func (wc *dockerVolumeWorkspaceCreator) unzipRepoIntoVolume(ctx context.Context, w *dockerVolumeWorkspace, zip string) error {
113112
// We want to mount that temporary file into a Docker container that has the
114113
// workspace volume attached, and unzip it into the volume.
114+
zipMount, err := docker.BindMount(zip, "/tmp/zip", true)
115+
if err != nil {
116+
return errors.Wrap(err, "creating archive mount")
117+
}
115118

116119
// We need to keep a temporary file in the volume before unzipping for the
117120
// permissions to persist because... reasons. Rather than reading the
@@ -157,7 +160,7 @@ func (wc *dockerVolumeWorkspaceCreator) unzipRepoIntoVolume(ctx context.Context,
157160
"--rm",
158161
"--init",
159162
"--workdir", "/work",
160-
"--mount", "type=bind,source=" + zip + ",target=/tmp/zip,ro",
163+
"--mount", zipMount,
161164
}, w.dockerRunOptsWithUser(w.uidGid, "/work")...)
162165
opts = append(
163166
opts,
@@ -177,6 +180,7 @@ func (wc *dockerVolumeWorkspaceCreator) copyFilesIntoVolumes(ctx context.Context
177180
if len(files) == 0 {
178181
return nil
179182
}
183+
const copyScript = `while test "$#" -gt 0; do cp "$1" "$2" || exit; shift 2; done`
180184

181185
opts := append([]string{
182186
"run",
@@ -192,22 +196,34 @@ func (wc *dockerVolumeWorkspaceCreator) copyFilesIntoVolumes(ctx context.Context
192196
}
193197
sort.Strings(names)
194198

195-
var copyCmds []string
196-
for _, name := range names {
199+
var copyArgs []string
200+
for i, name := range names {
201+
if err := validateWorkspaceFileName(name); err != nil {
202+
return err
203+
}
197204
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)
209+
if err != nil {
210+
return errors.Wrap(err, "creating additional file mount")
211+
}
198212
opts = append(opts, []string{
199-
"--mount", "type=bind,source=" + localPath + ",target=/tmp/" + name + ",ro",
213+
"--mount", mount,
200214
}...)
201215

202-
copyCmds = append(copyCmds, "cp /tmp/"+name+" /work/"+name)
216+
copyArgs = append(copyArgs, mountTarget, "/work/"+name)
203217
}
204218

205219
opts = append(
206220
opts,
207221
DockerVolumeWorkspaceImage,
208222
"sh", "-c",
209-
strings.Join(copyCmds, " && ")+";",
223+
copyScript,
224+
"copy-additional-files",
210225
)
226+
opts = append(opts, copyArgs...)
211227

212228
if out, err := exec.CommandContext(ctx, "docker", opts...).CombinedOutput(); err != nil {
213229
return errors.Wrapf(err, "unzip output:\n\n%s\n\n", string(out))
@@ -327,12 +343,17 @@ func (w *dockerVolumeWorkspace) runScript(ctx context.Context, target, script st
327343
return nil, errors.Wrap(err, "generating run options")
328344
}
329345

346+
scriptMount, err := docker.BindMount(name, "/run.sh", true)
347+
if err != nil {
348+
return nil, errors.Wrap(err, "creating run script mount")
349+
}
350+
330351
opts := append([]string{
331352
"run",
332353
"--rm",
333354
"--init",
334355
"--workdir", target,
335-
"--mount", "type=bind,source=" + name + ",target=/run.sh,ro",
356+
"--mount", scriptMount,
336357
}, common...)
337358
opts = append(opts, DockerVolumeWorkspaceImage, "sh", "/run.sh")
338359

0 commit comments

Comments
 (0)