Skip to content

Commit 7310cfe

Browse files
cbrnrdampagent
andcommitted
fix/batches: validate shell probe paths
Amp-Thread-ID: https://ampcode.com/threads/T-01a05d3c-cfaf-7540-9b4d-43c5e402c15b Co-authored-by: Amp <amp@ampcode.com>
1 parent 0636dac commit 7310cfe

3 files changed

Lines changed: 115 additions & 5 deletions

File tree

internal/batches/executor/run_steps.go

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import (
99
"maps"
1010
"os"
1111
"os/exec"
12+
"path"
1213
"path/filepath"
14+
"regexp"
1315
"strings"
1416
"time"
1517

@@ -348,21 +350,33 @@ func executeSingleStep(
348350
scriptWorkDir = workDir + "/" + opts.Task.Path
349351
}
350352

353+
if err := validateContainerTempPath(containerTemp); err != nil {
354+
return bytes.Buffer{}, bytes.Buffer{}, errors.Wrap(err, "validating run script target")
355+
}
356+
runScriptMount, err := dockerBindMount(runScriptFile, containerTemp)
357+
if err != nil {
358+
return bytes.Buffer{}, bytes.Buffer{}, errors.Wrap(err, "creating run script mount")
359+
}
360+
351361
args := append([]string{
352362
"run",
353363
"--rm",
354364
"--init",
355365
"--cidfile", cidFile,
356366
"--workdir", scriptWorkDir,
357-
"--mount", fmt.Sprintf("type=bind,source=%s,target=%s,ro", runScriptFile, containerTemp),
367+
"--mount", runScriptMount,
358368
}, workspaceOpts...)
359369

360370
if opts.ForceRoot {
361371
args = append(args, "--user", "0:0")
362372
}
363373

364374
for target, source := range filesToMount {
365-
args = append(args, "--mount", fmt.Sprintf("type=bind,source=%s,target=%s,ro", source.Name(), target))
375+
mountArg, err := dockerBindMount(source.Name(), target)
376+
if err != nil {
377+
return bytes.Buffer{}, bytes.Buffer{}, errors.Wrap(err, "creating files mount")
378+
}
379+
args = append(args, "--mount", mountArg)
366380
}
367381

368382
// Mount any paths on the local system to the docker container. The paths have already been validated during parsing.
@@ -371,7 +385,11 @@ func executeSingleStep(
371385
if err != nil {
372386
return bytes.Buffer{}, bytes.Buffer{}, err
373387
}
374-
args = append(args, "--mount", fmt.Sprintf("type=bind,source=%s,target=%s,ro", workspaceFilePath, mount.Mountpoint))
388+
mountArg, err := dockerBindMount(workspaceFilePath, mount.Mountpoint)
389+
if err != nil {
390+
return bytes.Buffer{}, bytes.Buffer{}, errors.Wrap(err, "creating host mount")
391+
}
392+
args = append(args, "--mount", mountArg)
375393
}
376394

377395
for k, v := range env {
@@ -479,6 +497,8 @@ func setOutputs(stepOutputs batcheslib.Outputs, global map[string]any, stepCtx *
479497
return nil
480498
}
481499

500+
var containerTempPathPattern = regexp.MustCompile(`^/[A-Za-z0-9_./-]+$`)
501+
482502
func probeImageForShell(ctx context.Context, image string) (shell, tempfile string, err error) {
483503
// We need to know two things to be able to run a shell script:
484504
//
@@ -508,9 +528,14 @@ func probeImageForShell(ctx context.Context, image string) (shell, tempfile stri
508528
if runErr := cmd.Run(); runErr != nil {
509529
err = errors.Append(err, errors.Wrapf(runErr, "probing shell %q:\n%s", shell, stderr.String()))
510530
} else {
531+
tempfile, runErr = parseContainerTempPath(stdout.String())
532+
if runErr != nil {
533+
err = errors.Append(err, errors.Wrapf(runErr, "probing shell %q", shell))
534+
continue
535+
}
536+
511537
// Even if there were previous errors, we can now ignore them.
512538
err = nil
513-
tempfile = strings.TrimSpace(stdout.String())
514539
return
515540
}
516541
}
@@ -520,6 +545,39 @@ func probeImageForShell(ctx context.Context, image string) (shell, tempfile stri
520545
return
521546
}
522547

548+
func parseContainerTempPath(output string) (string, error) {
549+
// mktemp writes one path followed by a newline. Remove that one expected
550+
// delimiter, then reject any additional output rather than allowing the
551+
// image to inject Docker's comma-delimited mount grammar.
552+
tempfile := strings.TrimSuffix(output, "\n")
553+
if tempfile == "" {
554+
return "", errors.New("mktemp returned an empty path")
555+
}
556+
if strings.ContainsAny(tempfile, "\r\n") {
557+
return "", errors.New("mktemp returned more than one line")
558+
}
559+
if err := validateContainerTempPath(tempfile); err != nil {
560+
return "", err
561+
}
562+
return tempfile, nil
563+
}
564+
565+
func validateContainerTempPath(tempfile string) error {
566+
if !path.IsAbs(tempfile) || !containerTempPathPattern.MatchString(tempfile) {
567+
return errors.Newf("mktemp returned invalid path %q", tempfile)
568+
}
569+
return nil
570+
}
571+
572+
func dockerBindMount(source, target string) (string, error) {
573+
for name, value := range map[string]string{"source": source, "target": target} {
574+
if value == "" || strings.ContainsAny(value, ",\r\n\x00") {
575+
return "", errors.Newf("invalid Docker mount %s %q", name, value)
576+
}
577+
}
578+
return fmt.Sprintf("type=bind,source=%s,target=%s,ro", source, target), nil
579+
}
580+
523581
// createFilesToMount creates temporary files with the contents of Step.Files
524582
// that are to be mounted into the container that executes the step.
525583
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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package executor
22

33
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"runtime"
48
"testing"
59

610
"github.com/stretchr/testify/require"
@@ -9,6 +13,54 @@ import (
913
"github.com/sourcegraph/sourcegraph/lib/batches/template"
1014
)
1115

16+
func TestParseContainerTempPath(t *testing.T) {
17+
for _, valid := range []string{"/tmp/tmp.abc-123_456", "/tmp/tmp.abc-123_456\n"} {
18+
t.Run("valid_"+valid, func(t *testing.T) {
19+
got, err := parseContainerTempPath(valid)
20+
require.NoError(t, err)
21+
require.Equal(t, "/tmp/tmp.abc-123_456", got)
22+
})
23+
}
24+
25+
for _, invalid := range []string{
26+
"",
27+
"\n",
28+
"relative/path\n",
29+
"/tmp/first\n/tmp/second\n",
30+
"/tmp/path\r\n",
31+
"/tmp/x,source=/var/run/docker.sock,target=/var/run/docker.sock\n",
32+
"/tmp/path with spaces\n",
33+
`/tmp/path"quoted`,
34+
`/tmp/path\backslash`,
35+
} {
36+
t.Run("invalid_"+invalid, func(t *testing.T) {
37+
_, err := parseContainerTempPath(invalid)
38+
require.Error(t, err)
39+
})
40+
}
41+
}
42+
43+
func TestProbeImageForShellRejectsMountInjection(t *testing.T) {
44+
if runtime.GOOS == "windows" {
45+
t.Skip("test uses a shell script as a fake docker executable")
46+
}
47+
48+
dir := t.TempDir()
49+
docker := filepath.Join(dir, "docker")
50+
err := os.WriteFile(docker, []byte("#!/bin/sh\nprintf '%s\\n' '/tmp/x,source=/var/run/docker.sock,target=/var/run/docker.sock'\n"), 0o755)
51+
require.NoError(t, err)
52+
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
53+
54+
_, _, err = probeImageForShell(context.Background(), "malicious-image")
55+
require.Error(t, err)
56+
require.Contains(t, err.Error(), "mktemp returned invalid path")
57+
}
58+
59+
func TestDockerBindMountRejectsMountGrammar(t *testing.T) {
60+
_, err := dockerBindMount("/tmp/script", "/tmp/x,source=/var/run/docker.sock")
61+
require.Error(t, err)
62+
}
63+
1264
func TestCreateFilesToMount_RejectsCommaInTargetPath(t *testing.T) {
1365
step := batcheslib.Step{
1466
Files: map[string]string{

internal/batches/executor/testdata/dummydocker/docker

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
# created a tempfile, or it executes the script supplied as the last arg to
88
# "run".
99

10-
dummy_temp_file="DUMMYDOCKER-TEMP-FILE"
10+
dummy_temp_file="/tmp/DUMMYDOCKER-TEMP-FILE"
1111

1212
workdir_prefix="/work/"
1313

0 commit comments

Comments
 (0)