Skip to content

Commit 8da27d2

Browse files
committed
Second attempt to address exploit
This builds off suggestion to reuse logic used already within `gh run download` for detecting path traversals. This largely works but runs into an issue where detection logic doesn't handle non-separated traversal.
1 parent 9decf1b commit 8da27d2

File tree

3 files changed

+110
-0
lines changed

3 files changed

+110
-0
lines changed

pkg/cmd/run/download/download.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,11 @@ func runDownload(opts *DownloadOptions) error {
169169
if len(wantPatterns) != 0 || len(wantNames) != 1 {
170170
destDir = filepath.Join(destDir, a.Name)
171171
}
172+
173+
if !filepathDescendsFrom(destDir, opts.DestinationDir) {
174+
return fmt.Errorf("error downloading %s: would result in path traversal", a.Name)
175+
}
176+
172177
err := opts.Platform.Download(a.DownloadURL, destDir)
173178
if err != nil {
174179
return fmt.Errorf("error downloading %s: %w", a.Name, err)

pkg/cmd/run/download/download_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,108 @@ func Test_runDownload(t *testing.T) {
289289
})
290290
},
291291
},
292+
{
293+
name: "given artifact name contains `..`, verify an error about path traversal is returned",
294+
opts: DownloadOptions{
295+
RunID: "2345",
296+
DestinationDir: ".",
297+
},
298+
mockAPI: func(p *mockPlatform) {
299+
p.On("List", "2345").Return([]shared.Artifact{
300+
{
301+
Name: "..",
302+
DownloadURL: "http://download.com/artifact1.zip",
303+
Expired: false,
304+
},
305+
}, nil)
306+
},
307+
wantErr: "error downloading ..: would result in path traversal",
308+
},
309+
{
310+
name: "given artifact name contains `..`, verify an error about path traversal is returned",
311+
opts: DownloadOptions{
312+
RunID: "2345",
313+
DestinationDir: "imaginary-dir",
314+
},
315+
mockAPI: func(p *mockPlatform) {
316+
p.On("List", "2345").Return([]shared.Artifact{
317+
{
318+
Name: "..",
319+
DownloadURL: "http://download.com/artifact1.zip",
320+
Expired: false,
321+
},
322+
}, nil)
323+
},
324+
wantErr: "error downloading ..: would result in path traversal",
325+
},
326+
{
327+
name: "given artifact name contains `../etc/passwd`, verify an error about path traversal is returned",
328+
opts: DownloadOptions{
329+
RunID: "2345",
330+
DestinationDir: ".",
331+
},
332+
mockAPI: func(p *mockPlatform) {
333+
p.On("List", "2345").Return([]shared.Artifact{
334+
{
335+
Name: "../etc/passwd",
336+
DownloadURL: "http://download.com/artifact1.zip",
337+
Expired: false,
338+
},
339+
}, nil)
340+
},
341+
wantErr: "error downloading ../etc/passwd: would result in path traversal",
342+
},
343+
{
344+
name: "given artifact name contains `../etc/passwd`, verify an error about path traversal is returned",
345+
opts: DownloadOptions{
346+
RunID: "2345",
347+
DestinationDir: "imaginary-dir",
348+
},
349+
mockAPI: func(p *mockPlatform) {
350+
p.On("List", "2345").Return([]shared.Artifact{
351+
{
352+
Name: "../etc/passwd",
353+
DownloadURL: "http://download.com/artifact1.zip",
354+
Expired: false,
355+
},
356+
}, nil)
357+
},
358+
wantErr: "error downloading ../etc/passwd: would result in path traversal",
359+
},
360+
{
361+
name: "given artifact name contains `../../etc/passwd`, verify an error about path traversal is returned",
362+
opts: DownloadOptions{
363+
RunID: "2345",
364+
DestinationDir: ".",
365+
},
366+
mockAPI: func(p *mockPlatform) {
367+
p.On("List", "2345").Return([]shared.Artifact{
368+
{
369+
Name: "../../etc/passwd",
370+
DownloadURL: "http://download.com/artifact1.zip",
371+
Expired: false,
372+
},
373+
}, nil)
374+
},
375+
wantErr: "error downloading ../../etc/passwd: would result in path traversal",
376+
},
377+
{
378+
name: "given artifact name contains `../../etc/passwd`, verify an error about path traversal is returned",
379+
opts: DownloadOptions{
380+
RunID: "2345",
381+
DestinationDir: "imaginary-dir",
382+
},
383+
mockAPI: func(p *mockPlatform) {
384+
p.On("List", "2345").Return([]shared.Artifact{
385+
{
386+
Name: "../../etc/passwd",
387+
DownloadURL: "http://download.com/artifact1.zip",
388+
Expired: false,
389+
},
390+
}, nil)
391+
},
392+
wantErr: "error downloading ../../etc/passwd: would result in path traversal",
393+
},
292394
}
293395
for _, tt := range tests {
294396
t.Run(tt.name, func(t *testing.T) {

pkg/cmd/run/download/zip.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ func getPerm(m os.FileMode) os.FileMode {
7373
func filepathDescendsFrom(p, dir string) bool {
7474
p = filepath.Clean(p)
7575
dir = filepath.Clean(dir)
76+
if dir == "." && p == ".." {
77+
return false
78+
}
7679
if dir == "." && !filepath.IsAbs(p) {
7780
return !strings.HasPrefix(p, ".."+string(filepath.Separator))
7881
}

0 commit comments

Comments
 (0)