Skip to content

Commit 35e43c5

Browse files
authored
Merge branch 'main' into carterbrainerd-vuln-143-src-batch-volume-mode-a-server-supplied-workspacepath-is
2 parents 345ef5b + ed0828d commit 35e43c5

6 files changed

Lines changed: 133 additions & 5 deletions

File tree

‎internal/batches/executor/run_steps.go‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,9 @@ func executeSingleStep(
317317
}
318318
defer cleanup()
319319

320-
// Resolve step.Env given the current environment.
321-
stepEnv, err := step.Env.Resolve(opts.GlobalEnv)
320+
// Resolve step.Env given the current environment. Executor control values
321+
// must never be selectable by an author-controlled step.
322+
stepEnv, err := step.Env.Resolve(withoutReservedExecutorEnv(opts.GlobalEnv))
322323
if err != nil {
323324
err = errors.Wrap(err, "resolving step environment")
324325
opts.UI.StepPreparingFailed(stepIdx+1, err)
@@ -465,6 +466,17 @@ func executeSingleStep(
465466
return stdout, stderr, nil
466467
}
467468

469+
func withoutReservedExecutorEnv(env []string) []string {
470+
filtered := make([]string, 0, len(env))
471+
for _, variable := range env {
472+
name, _, found := strings.Cut(variable, "=")
473+
if !found || !strings.HasPrefix(name, "SRC_EXECUTOR_") {
474+
filtered = append(filtered, variable)
475+
}
476+
}
477+
return filtered
478+
}
479+
468480
func setOutputs(stepOutputs batcheslib.Outputs, global map[string]any, stepCtx *template.StepContext) error {
469481
for name, output := range stepOutputs {
470482
var value bytes.Buffer

‎internal/batches/executor/run_steps_test.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package executor
22

33
import (
44
"context"
5+
"encoding/json"
56
"os"
67
"path/filepath"
78
"runtime"
@@ -10,9 +11,40 @@ import (
1011
"github.com/stretchr/testify/require"
1112

1213
batcheslib "github.com/sourcegraph/sourcegraph/lib/batches"
14+
batchenv "github.com/sourcegraph/sourcegraph/lib/batches/env"
1315
"github.com/sourcegraph/sourcegraph/lib/batches/template"
1416
)
1517

18+
func TestWithoutReservedExecutorEnv(t *testing.T) {
19+
env := []string{
20+
"ALLOWED=value",
21+
"SRC_EXECUTOR_JOB_TOKEN=secret",
22+
"SRC_EXECUTOR_FUTURE_SECRET=secret",
23+
"VALUE=contains-SRC_EXECUTOR_JOB_TOKEN",
24+
"MALFORMED",
25+
}
26+
27+
require.Equal(t, []string{
28+
"ALLOWED=value",
29+
"VALUE=contains-SRC_EXECUTOR_JOB_TOKEN",
30+
"MALFORMED",
31+
}, withoutReservedExecutorEnv(env))
32+
33+
var stepEnv batchenv.Environment
34+
require.NoError(t, json.Unmarshal([]byte(`[
35+
"ALLOWED",
36+
"SRC_EXECUTOR_JOB_TOKEN",
37+
"SRC_EXECUTOR_FUTURE_SECRET"
38+
]`), &stepEnv))
39+
resolved, err := stepEnv.Resolve(withoutReservedExecutorEnv(env[:4]))
40+
require.NoError(t, err)
41+
require.Equal(t, map[string]string{
42+
"ALLOWED": "value",
43+
"SRC_EXECUTOR_JOB_TOKEN": "",
44+
"SRC_EXECUTOR_FUTURE_SECRET": "",
45+
}, resolved)
46+
}
47+
1648
func TestParseContainerTempPath(t *testing.T) {
1749
for _, valid := range []string{"/tmp/tmp.abc-123_456", "/tmp/tmp.abc-123_456\n"} {
1850
t.Run("valid_"+valid, func(t *testing.T) {

‎internal/batches/workspace/bind_workspace_test.go‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,8 @@ func TestDockerBindWorkspace_DiffRestoresTrustedGitConfig(t *testing.T) {
234234
}
235235

236236
dir := *workspace.WorkDir()
237-
configPath := filepath.Join(dir, ".git", "config")
237+
dotGit := filepath.Join(dir, ".git")
238+
configPath := filepath.Join(dotGit, "config")
238239
trustedConfig, err := os.ReadFile(configPath)
239240
if err != nil {
240241
t.Fatal(err)
@@ -249,6 +250,26 @@ func TestDockerBindWorkspace_DiffRestoresTrustedGitConfig(t *testing.T) {
249250
if err := config.Close(); err != nil {
250251
t.Fatal(err)
251252
}
253+
254+
// A commondir file redirects Git to the config in another directory. That
255+
// config must not survive metadata restoration and reach host-side Git.
256+
attackerCommon := filepath.Join(dir, "attacker-common")
257+
if err := os.CopyFS(attackerCommon, os.DirFS(dotGit)); err != nil {
258+
t.Fatal(err)
259+
}
260+
attackerConfig, err := os.OpenFile(filepath.Join(attackerCommon, "config"), os.O_APPEND|os.O_WRONLY, 0)
261+
if err != nil {
262+
t.Fatal(err)
263+
}
264+
if _, err := attackerConfig.WriteString("[filter \"attack\"]\n\tclean = command-that-must-not-run\n\trequired = true\n"); err != nil {
265+
t.Fatal(err)
266+
}
267+
if err := attackerConfig.Close(); err != nil {
268+
t.Fatal(err)
269+
}
270+
if err := os.WriteFile(filepath.Join(dotGit, "commondir"), []byte("../attacker-common\n"), 0644); err != nil {
271+
t.Fatal(err)
272+
}
252273
if err := os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte("*.txt filter=attack\n"), 0644); err != nil {
253274
t.Fatal(err)
254275
}
@@ -270,6 +291,9 @@ func TestDockerBindWorkspace_DiffRestoresTrustedGitConfig(t *testing.T) {
270291
if !cmp.Equal(restoredConfig, trustedConfig) {
271292
t.Fatalf("Git config was not restored:\n%s", cmp.Diff(string(trustedConfig), string(restoredConfig)))
272293
}
294+
if _, err := os.Stat(filepath.Join(dotGit, "commondir")); !os.IsNotExist(err) {
295+
t.Fatalf("untrusted commondir was not removed: %v", err)
296+
}
273297
}
274298

275299
func TestUnzipRejectsGitMetadata(t *testing.T) {

‎internal/batches/workspace/git.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
type gitMetadataSnapshot struct {
1515
dotGit *gitControlFile
16+
commonDir *gitControlFile
1617
config *gitControlFile
1718
configWorktree *gitControlFile
1819
}
@@ -34,7 +35,10 @@ func snapshotGitMetadata(dir string) (*gitMetadataSnapshot, error) {
3435
case info.Mode().IsRegular():
3536
snapshot.dotGit, err = snapshotGitControlFile(dotGit)
3637
case info.IsDir():
37-
snapshot.config, err = snapshotGitControlFile(filepath.Join(dotGit, "config"))
38+
snapshot.commonDir, err = snapshotOptionalGitControlFile(filepath.Join(dotGit, "commondir"))
39+
if err == nil {
40+
snapshot.config, err = snapshotGitControlFile(filepath.Join(dotGit, "config"))
41+
}
3842
if err == nil {
3943
snapshot.configWorktree, err = snapshotOptionalGitControlFile(filepath.Join(dotGit, "config.worktree"))
4044
}
@@ -87,6 +91,11 @@ func (s *gitMetadataSnapshot) restore(dir string) error {
8791
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
8892
return fmt.Errorf("%s is no longer a directory", dotGit)
8993
}
94+
// commondir changes which repository config Git reads. Restore it before
95+
// any host-side Git command can follow an attacker-controlled redirect.
96+
if err := restoreGitControlFile(filepath.Join(dotGit, "commondir"), s.commonDir); err != nil {
97+
return err
98+
}
9099
if err := restoreGitControlFile(filepath.Join(dotGit, "config"), s.config); err != nil {
91100
return err
92101
}

‎internal/servegit/gitservice.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,18 @@ func (s *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
105105
http.Error(w, "invalid path specified: "+err.Error(), http.StatusBadRequest)
106106
return
107107
}
108-
if _, err = s.RootFS.Stat(relDir); os.IsNotExist(err) {
108+
info, err := s.RootFS.Stat(relDir)
109+
if os.IsNotExist(err) {
109110
http.Error(w, "repository not found", http.StatusNotFound)
110111
return
111112
} else if err != nil {
112113
http.Error(w, "failed to stat repo: "+err.Error(), http.StatusInternalServerError)
113114
return
114115
}
116+
if !info.IsDir() {
117+
http.Error(w, "repository not found", http.StatusNotFound)
118+
return
119+
}
115120

116121
body := r.Body
117122
defer body.Close()

‎internal/servegit/serve_test.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,52 @@ func TestReposHandler(t *testing.T) {
6969
}
7070
}
7171

72+
func TestHandlerRejectsGitdirFile(t *testing.T) {
73+
root := t.TempDir()
74+
servedRoot := filepath.Join(root, "served")
75+
publicRepo := filepath.Join(servedRoot, "public")
76+
privateRepo := filepath.Join(root, "private")
77+
for _, repo := range []string{publicRepo, privateRepo} {
78+
if err := os.MkdirAll(repo, 0755); err != nil {
79+
t.Fatal(err)
80+
}
81+
}
82+
gitInit(t, publicRepo)
83+
gitInit(t, privateRepo)
84+
85+
pointer := filepath.Join(publicRepo, "gitdir-pointer")
86+
if err := os.WriteFile(pointer, []byte("gitdir: ../../private/.git\n"), 0644); err != nil {
87+
t.Fatal(err)
88+
}
89+
runCmd(t, publicRepo, "git", "add", "gitdir-pointer")
90+
runCmd(t, publicRepo, "git", "commit", "-m", "add gitdir pointer")
91+
runCmd(t, privateRepo, "git", "commit", "--allow-empty", "-m", "private commit")
92+
// Git accepts this ordinary tracked file as a repository and follows its
93+
// gitdir pointer outside servedRoot.
94+
runCmd(t, publicRepo, "git", "upload-pack", "--strict", "--advertise-refs", pointer)
95+
96+
rootFS, err := os.OpenRoot(servedRoot)
97+
if err != nil {
98+
t.Fatal(err)
99+
}
100+
t.Cleanup(func() { rootFS.Close() })
101+
102+
h := (&Serve{
103+
Info: testLogger(t),
104+
Debug: discardLogger,
105+
Addr: testAddress,
106+
Root: servedRoot,
107+
RootFS: rootFS,
108+
}).handler()
109+
req := httptest.NewRequest(http.MethodGet, "/repos/public/gitdir-pointer/info/refs?service=git-upload-pack", nil)
110+
rec := httptest.NewRecorder()
111+
h.ServeHTTP(rec, req)
112+
113+
if rec.Code != http.StatusNotFound {
114+
t.Fatalf("gitdir file status = %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
115+
}
116+
}
117+
72118
func testReposHandler(t *testing.T, h http.Handler, repos []Repo) {
73119
ts := httptest.NewServer(h)
74120
t.Cleanup(ts.Close)

0 commit comments

Comments
 (0)