Skip to content

Commit a6da134

Browse files
committed
fix/security: shell escape template variables
1 parent ed8850d commit a6da134

6 files changed

Lines changed: 231 additions & 17 deletions

File tree

cmd/src/batch_common.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,18 +207,28 @@ func batchDefaultCacheDir() string {
207207
// Otherwise we use "/tmp".
208208
func batchDefaultTempDirPrefix() string {
209209
if p := os.Getenv("SRC_BATCH_TMP_DIR"); p != "" {
210-
return p
210+
return resolveSymlinks(p)
211211
}
212212

213213
// On macOS, we use an explicit prefix for our temp directories, because
214214
// otherwise Go would use $TMPDIR, which is set to `/var/folders` per
215215
// default on macOS. But Docker for Mac doesn't have `/var/folders` in its
216216
// default set of shared folders, but it does have `/tmp` in there.
217+
//
218+
// We must resolve symlinks because /tmp is a symlink to /private/tmp on
219+
// macOS, and Docker for Mac only shares the real path, not the symlink.
217220
if runtime.GOOS == "darwin" {
218-
return "/tmp"
221+
return resolveSymlinks("/tmp")
219222
}
220223

221-
return os.TempDir()
224+
return resolveSymlinks(os.TempDir())
225+
}
226+
227+
func resolveSymlinks(path string) string {
228+
if resolved, err := filepath.EvalSymlinks(path); err == nil {
229+
return resolved
230+
}
231+
return path
222232
}
223233

224234
func batchOpenFileFlag(flag string) (*os.File, error) {

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ require (
1919
github.com/jedib0t/go-pretty/v6 v6.6.3
2020
github.com/jig/teereadcloser v0.0.0-20181016160506-953720c48e05
2121
github.com/json-iterator/go v1.1.12
22-
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
2322
github.com/mattn/go-isatty v0.0.20
2423
github.com/neelance/parallel v0.0.0-20160708114440-4de9ce63d14c
2524
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
@@ -75,6 +74,7 @@ require (
7574
github.com/jackc/pgpassfile v1.0.0 // indirect
7675
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
7776
github.com/jackc/pgx/v5 v5.9.2 // indirect
77+
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
7878
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
7979
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
8080
github.com/sasha-s/go-deadlock v0.3.5 // indirect

lib/batches/template/templating.go

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/gobwas/glob"
1212
"github.com/grafana/regexp"
1313

14+
"github.com/kballard/go-shellquote"
1415
"github.com/sourcegraph/sourcegraph/lib/batches/execution"
1516
"github.com/sourcegraph/sourcegraph/lib/batches/git"
1617
"github.com/sourcegraph/sourcegraph/lib/errors"
@@ -19,10 +20,28 @@ import (
1920
const startDelim = "${{"
2021
const endDelim = "}}"
2122

23+
// shellEscapeAll returns a copy of in where every element has been quoted
24+
// with shellquote.Join so that it is safe to splat into a /bin/sh command line.
25+
// Elements without shell metacharacters are returned unmodified, so callers
26+
// that pre-existed this escaping (e.g. `${{ join steps.modified_files " " }}`
27+
// against safe filenames) continue to see the same rendered output.
28+
//
29+
// 🚨 SECURITY: This is used to defang attacker-controlled filenames coming
30+
// out of `git diff` parsing before they reach the rendered step script. See
31+
// VULN-91 / HackerOne report 3767160.
32+
func shellEscapeAll(in ...string) []string {
33+
out := make([]string, len(in))
34+
for i, s := range in {
35+
out[i] = shellquote.Join(s)
36+
}
37+
return out
38+
}
39+
2240
var builtins = template.FuncMap{
23-
"join": strings.Join,
24-
"split": strings.Split,
25-
"replace": strings.ReplaceAll,
41+
"join": strings.Join,
42+
"shellquote_join": shellquote.Join,
43+
"split": strings.Split,
44+
"replace": strings.ReplaceAll,
2645
"join_if": func(sep string, elems ...string) string {
2746
var nonBlank []string
2847
for _, e := range elems {
@@ -163,9 +182,23 @@ type Repository struct {
163182
FileMatches []string
164183
}
165184

185+
// SearchResultPaths returns the repository's matched paths in a form that is
186+
// safe to splat into a /bin/sh command line.
187+
//
188+
// 🚨 SECURITY: paths originate from a Sourcegraph search and ultimately from
189+
// `git`, so they may contain attacker-controlled shell metacharacters (see
190+
// VULN-91). Every element is run through shellquote.Join before it is exposed
191+
// to the step template. Elements without metacharacters are returned
192+
// unmodified, so existing usage like `${{ join repository.search_result_paths
193+
// " " }}` keeps producing the same output for benign filenames.
166194
func (r Repository) SearchResultPaths() (list fileMatchPathList) {
167-
sort.Strings(r.FileMatches)
168-
return r.FileMatches
195+
paths := make([]string, len(r.FileMatches))
196+
copy(paths, r.FileMatches)
197+
sort.Strings(paths)
198+
for i, p := range paths {
199+
paths[i] = shellquote.Join(p)
200+
}
201+
return paths
169202
}
170203

171204
type fileMatchPathList []string
@@ -208,10 +241,24 @@ func (stepCtx *StepContext) ToFuncMap() template.FuncMap {
208241
return m
209242
}
210243

211-
m["modified_files"] = res.ChangedFiles.Modified
212-
m["added_files"] = res.ChangedFiles.Added
213-
m["deleted_files"] = res.ChangedFiles.Deleted
214-
m["renamed_files"] = res.ChangedFiles.Renamed
244+
// 🚨 SECURITY: file lists are derived from `git diff` output and can
245+
// contain attacker-controlled filenames with shell metacharacters.
246+
// We shell-escape each element before exposing them to the step
247+
// template to prevent command injection when the rendered template
248+
// is executed by /bin/sh. The slice shape is preserved so
249+
// `${{ join … " " }}` and `${{ range … }}` continue to work as
250+
// before. See VULN-91.
251+
//
252+
// NOTE: stdout/stderr are intentionally NOT pre-escaped. They are
253+
// commonly captured into `outputs` and reused as plain values (e.g.
254+
// a filename written by `echo`), where pre-quoting would change
255+
// semantics. Users that splat stdout/stderr into a shell command
256+
// against untrusted data should pipe through the `shellquote_join`
257+
// builtin: `${{ shellquote_join previous_step.stdout }}`.
258+
m["modified_files"] = shellEscapeAll(res.ChangedFiles.Modified...)
259+
m["added_files"] = shellEscapeAll(res.ChangedFiles.Added...)
260+
m["deleted_files"] = shellEscapeAll(res.ChangedFiles.Deleted...)
261+
m["renamed_files"] = shellEscapeAll(res.ChangedFiles.Renamed...)
215262
m["stdout"] = res.Stdout
216263
m["stderr"] = res.Stderr
217264

@@ -295,11 +342,13 @@ func (tmplCtx *ChangesetTemplateContext) ToFuncMap() template.FuncMap {
295342
return tmplCtx.Outputs
296343
},
297344
"steps": func() map[string]any {
345+
// 🚨 SECURITY: shell-escape per element to defang attacker
346+
// controlled filenames from `git diff`. See VULN-91.
298347
return map[string]any{
299-
"modified_files": tmplCtx.Steps.Changes.Modified,
300-
"added_files": tmplCtx.Steps.Changes.Added,
301-
"deleted_files": tmplCtx.Steps.Changes.Deleted,
302-
"renamed_files": tmplCtx.Steps.Changes.Renamed,
348+
"modified_files": shellEscapeAll(tmplCtx.Steps.Changes.Modified...),
349+
"added_files": shellEscapeAll(tmplCtx.Steps.Changes.Added...),
350+
"deleted_files": shellEscapeAll(tmplCtx.Steps.Changes.Deleted...),
351+
"renamed_files": shellEscapeAll(tmplCtx.Steps.Changes.Renamed...),
303352
"path": tmplCtx.Steps.Path,
304353
}
305354
},
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package template
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
8+
"github.com/sourcegraph/sourcegraph/lib/batches/execution"
9+
"github.com/sourcegraph/sourcegraph/lib/batches/git"
10+
)
11+
12+
// TestVULN91_NoShellInjectionFromFilenames is a regression test for VULN-91
13+
// (HackerOne report 3767160). It verifies that attacker-controlled filenames
14+
// surfaced through the various filename-bearing template variables cannot
15+
// inject shell metacharacters into the rendered step script.
16+
//
17+
// The PoC in the report relies on filenames like
18+
//
19+
// `id > /tmp/PWNED`.go
20+
// foo.go; echo INJECTED; #.go
21+
//
22+
// being rendered verbatim through `${{ join repository.search_result_paths " " }}`
23+
// and friends, then executed under /bin/sh. After the fix, each filename must
24+
// be shell-quoted so the metacharacters lose their shell meaning.
25+
func TestVULN91_NoShellInjectionFromFilenames(t *testing.T) {
26+
maliciousFilenames := []string{
27+
"main.go",
28+
"`id > /tmp/PWNED && cat /tmp/PWNED`.go",
29+
"foo.go; echo INJECTED_$(whoami) > /tmp/PWNED2; #.go",
30+
"with space.go",
31+
"with'quote.go",
32+
"with\nnewline.go",
33+
}
34+
35+
// These template snippets all unconditionally splat filename-bearing
36+
// variables into a shell command. None of them may contain raw shell
37+
// metacharacters after rendering.
38+
templates := map[string]string{
39+
"step run via repository.search_result_paths": `gofmt -w ${{ join repository.search_result_paths " " }}`,
40+
"step run via previous_step.modified_files": `gofmt -w ${{ join previous_step.modified_files " " }}`,
41+
"step run via previous_step.added_files": `gofmt -w ${{ join previous_step.added_files " " }}`,
42+
"step run via previous_step.deleted_files": `rm -f ${{ join previous_step.deleted_files " " }}`,
43+
"step run via previous_step.renamed_files": `touch ${{ join previous_step.renamed_files " " }}`,
44+
"step run via steps.modified_files": `gofmt -w ${{ join steps.modified_files " " }}`,
45+
"step run via direct repository fmt": `gofmt -w ${{ repository.search_result_paths }}`,
46+
}
47+
48+
stepCtx := &StepContext{
49+
Repository: Repository{
50+
Name: "github.com/example/repo",
51+
Branch: "main",
52+
FileMatches: maliciousFilenames,
53+
},
54+
PreviousStep: execution.AfterStepResult{
55+
ChangedFiles: git.Changes{
56+
Modified: maliciousFilenames,
57+
Added: maliciousFilenames,
58+
Deleted: maliciousFilenames,
59+
Renamed: maliciousFilenames,
60+
},
61+
},
62+
Steps: StepsContext{
63+
Changes: git.Changes{
64+
Modified: maliciousFilenames,
65+
Added: maliciousFilenames,
66+
Deleted: maliciousFilenames,
67+
Renamed: maliciousFilenames,
68+
},
69+
},
70+
}
71+
72+
// Tokens that, if they appear unescaped in the rendered output, would
73+
// trigger shell command substitution, command chaining, or redirection.
74+
dangerousSubstrings := []string{
75+
"`id",
76+
"$(",
77+
"; echo",
78+
"> /tmp/PWNED",
79+
"PWNED2",
80+
}
81+
82+
for name, tmpl := range templates {
83+
t.Run(name, func(t *testing.T) {
84+
var out bytes.Buffer
85+
if err := RenderStepTemplate("vuln-91", tmpl, &out, stepCtx); err != nil {
86+
t.Fatalf("RenderStepTemplate returned error: %v", err)
87+
}
88+
rendered := out.String()
89+
for _, bad := range dangerousSubstrings {
90+
if containsUnquoted(rendered, bad) {
91+
t.Errorf("rendered output contains unescaped shell metasequence %q\nrendered: %s", bad, rendered)
92+
}
93+
}
94+
})
95+
}
96+
97+
// Also exercise the ChangesetTemplateContext path.
98+
tmplCtx := &ChangesetTemplateContext{
99+
Repository: Repository{
100+
Name: "github.com/example/repo",
101+
Branch: "main",
102+
FileMatches: maliciousFilenames,
103+
},
104+
Steps: StepsContext{
105+
Changes: git.Changes{
106+
Modified: maliciousFilenames,
107+
Added: maliciousFilenames,
108+
Deleted: maliciousFilenames,
109+
Renamed: maliciousFilenames,
110+
},
111+
},
112+
}
113+
ctTemplates := map[string]string{
114+
"changeset via repository.search_result_paths": `body: ${{ join repository.search_result_paths " " }}`,
115+
"changeset via steps.modified_files": `body: ${{ join steps.modified_files " " }}`,
116+
}
117+
for name, tmpl := range ctTemplates {
118+
t.Run(name, func(t *testing.T) {
119+
rendered, err := RenderChangesetTemplateField("vuln-91", tmpl, tmplCtx)
120+
if err != nil {
121+
t.Fatalf("RenderChangesetTemplateField returned error: %v", err)
122+
}
123+
for _, bad := range dangerousSubstrings {
124+
if containsUnquoted(rendered, bad) {
125+
t.Errorf("rendered output contains unescaped shell metasequence %q\nrendered: %s", bad, rendered)
126+
}
127+
}
128+
})
129+
}
130+
}
131+
132+
// containsUnquoted reports whether needle occurs in haystack outside of a
133+
// single-quoted shell context. After shellquote.Join, dangerous bytes are
134+
// either backslash-escaped (e.g. \`) or wrapped in single quotes that
135+
// terminate the surrounding string literal, so any naive substring match
136+
// reliably finds an injection. We additionally allow the substring to appear
137+
// inside a single-quoted region (which shell does NOT interpret) to avoid
138+
// false positives on the literal text of the (now safely quoted) filename.
139+
func containsUnquoted(haystack, needle string) bool {
140+
for i := 0; i+len(needle) <= len(haystack); i++ {
141+
if haystack[i:i+len(needle)] != needle {
142+
continue
143+
}
144+
// Count single quotes before this position; if odd, we're inside a
145+
// single-quoted region and the bytes are harmless.
146+
quotes := strings.Count(haystack[:i], "'")
147+
if quotes%2 == 0 {
148+
return true
149+
}
150+
}
151+
return false
152+
}

lib/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ require (
5656
github.com/gorilla/css v1.0.1 // indirect
5757
github.com/jackc/pgpassfile v1.0.0 // indirect
5858
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
59+
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
5960
github.com/klauspost/compress v1.18.0 // indirect
6061
github.com/kr/pretty v0.3.1 // indirect
6162
github.com/kr/text v0.2.0 // indirect

lib/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
9393
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
9494
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
9595
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
96+
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
97+
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
9698
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
9799
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
98100
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=

0 commit comments

Comments
 (0)