Skip to content

Commit 6259aee

Browse files
fix(storage): skip download of file outside of target dir (#12945)
This PR introduces logic in transfermanager download directories flow where the objects are skipped if the download path is outside of target directory. The changes directly adds the results of such objects using addResult method bypassing the addition of such objects to workers threads
1 parent bafd691 commit 6259aee

File tree

3 files changed

+380
-7
lines changed

3 files changed

+380
-7
lines changed

storage/transfermanager/downloader.go

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ func (d *Downloader) DownloadDirectory(ctx context.Context, input *DownloadDirec
181181

182182
outs := make(chan DownloadOutput, len(objectsToQueue))
183183
inputs := make([]DownloadObjectInput, 0, len(objectsToQueue))
184+
illegalPathObjects := make([]DownloadObjectInput, 0, len(objectsToQueue))
184185

185186
for _, object := range objectsToQueue {
186187
objectWithoutPrefix := object
@@ -191,8 +192,26 @@ func (d *Downloader) DownloadDirectory(ctx context.Context, input *DownloadDirec
191192
objDirectory := filepath.Join(input.LocalDirectory, filepath.Dir(objectWithoutPrefix))
192193
filePath := filepath.Join(input.LocalDirectory, objectWithoutPrefix)
193194

195+
// Prevent directory traversal attacks.
196+
isUnder, err := isSubPath(input.LocalDirectory, filePath)
197+
if err != nil {
198+
cleanFiles(inputs)
199+
return fmt.Errorf("transfermanager: DownloadDirectory failed to verify path: %w", err)
200+
}
201+
if !isUnder {
202+
// skipped files will later be added in the results
203+
illegalPathObjects = append(illegalPathObjects, DownloadObjectInput{
204+
Bucket: input.Bucket,
205+
Object: object,
206+
Callback: input.OnObjectDownload,
207+
ctx: ctx,
208+
directoryObjectOutputs: outs,
209+
directory: true,
210+
})
211+
continue
212+
}
194213
// Make sure all directories in the object path exist.
195-
err := os.MkdirAll(objDirectory, fs.ModeDir|fs.ModePerm)
214+
err = os.MkdirAll(objDirectory, fs.ModeDir|fs.ModePerm)
196215
if err != nil {
197216
cleanFiles(inputs)
198217
return fmt.Errorf("transfermanager: DownloadDirectory failed to make directory(%q): %w", objDirectory, err)
@@ -218,9 +237,21 @@ func (d *Downloader) DownloadDirectory(ctx context.Context, input *DownloadDirec
218237

219238
if d.config.asynchronous {
220239
d.downloadsInProgress.Add(1)
221-
go d.gatherObjectOutputs(input, outs, len(inputs))
240+
allObjectsCount := len(inputs) + len(illegalPathObjects)
241+
go d.gatherObjectOutputs(input, outs, allObjectsCount)
222242
}
223243
d.addNewInputs(inputs)
244+
245+
for _, file := range illegalPathObjects {
246+
// the waitgroup is further decremented in addResult method
247+
d.downloadsInProgress.Add(1)
248+
d.addResult(&file, &DownloadOutput{
249+
Bucket: file.Bucket,
250+
Object: file.Object,
251+
Err: fmt.Errorf("skipping download of object with unsafe path %q", file.Object),
252+
skipped: true,
253+
})
254+
}
224255
return nil
225256
}
226257

@@ -311,7 +342,7 @@ func (d *Downloader) addNewInputs(inputs []DownloadObjectInput) {
311342
func (d *Downloader) addResult(input *DownloadObjectInput, result *DownloadOutput) {
312343
copiedResult := *result // make a copy so that callbacks do not affect the result
313344

314-
if input.directory {
345+
if input.directory && !result.skipped {
315346
f := input.Destination.(*os.File)
316347
if err := f.Close(); err != nil && result.Err == nil {
317348
result.Err = fmt.Errorf("closing file(%q): %w", f.Name(), err)
@@ -321,10 +352,9 @@ func (d *Downloader) addResult(input *DownloadObjectInput, result *DownloadOutpu
321352
if result.Err != nil {
322353
os.Remove(f.Name())
323354
}
324-
325-
if d.config.asynchronous {
326-
input.directoryObjectOutputs <- copiedResult
327-
}
355+
}
356+
if input.directory && d.config.asynchronous {
357+
input.directoryObjectOutputs <- copiedResult
328358
}
329359

330360
if d.config.asynchronous || input.directory {
@@ -810,6 +840,7 @@ type DownloadOutput struct {
810840
Err error // error occurring during download
811841
Attrs *storage.ReaderObjectAttrs // attributes of downloaded object, if successful
812842

843+
skipped bool
813844
shard int
814845
shardLength int64
815846
crc32c uint32
@@ -948,3 +979,28 @@ func checksumObject(got, want uint32) error {
948979
}
949980
return nil
950981
}
982+
983+
func isSubPath(localDirectory, filePath string) (bool, error) {
984+
// Validate if paths can be converted to absolute paths.
985+
absLocalDirectory, err := filepath.Abs(localDirectory)
986+
if err != nil {
987+
return false, fmt.Errorf("cannot convert local directory to absolute path: %w", err)
988+
}
989+
absFilePath, err := filepath.Abs(filePath)
990+
if err != nil {
991+
return false, fmt.Errorf("cannot convert file path to absolute path: %w", err)
992+
}
993+
994+
// The relative path from the local directory to the file path.
995+
// ex: if localDirectory is /tmp/foo and filePath is /tmp/foo/bar, rel will be "bar".
996+
rel, err := filepath.Rel(absLocalDirectory, absFilePath)
997+
if err != nil {
998+
return false, err
999+
}
1000+
1001+
// rel should not start with ".." to escape target directory
1002+
prevDir := ".." + string(filepath.Separator)
1003+
isUnder := !strings.HasPrefix(rel, prevDir) && rel != ".."
1004+
1005+
return isUnder, nil
1006+
}

storage/transfermanager/downloader_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"bytes"
1919
"context"
2020
"errors"
21+
"os"
22+
"path/filepath"
2123
"strings"
2224
"sync"
2325
"testing"
@@ -232,6 +234,117 @@ func TestNumShards(t *testing.T) {
232234
}
233235
}
234236

237+
func TestIsSubPath(t *testing.T) {
238+
t.Parallel()
239+
sep := string(filepath.Separator)
240+
241+
// Create a temporary directory to work with relative paths.
242+
tempDir := t.TempDir()
243+
244+
testCases := []struct {
245+
name string
246+
localDirectory string
247+
filePath string
248+
wantIsSub bool
249+
wantErrMsg string
250+
}{
251+
{
252+
name: "filePath is a child",
253+
localDirectory: "/tmp/foo",
254+
filePath: "/tmp/foo/bar",
255+
wantIsSub: true,
256+
},
257+
{
258+
name: "filePath is a nested child",
259+
localDirectory: "/tmp/foo",
260+
filePath: "/tmp/foo/bar/baz",
261+
wantIsSub: true,
262+
},
263+
{
264+
name: "filePath is the same as localDirectory",
265+
localDirectory: "/tmp/foo",
266+
filePath: "/tmp/foo",
267+
wantIsSub: true,
268+
},
269+
{
270+
name: "filePath is a sibling",
271+
localDirectory: "/tmp/foo",
272+
filePath: "/tmp/bar",
273+
wantIsSub: false,
274+
},
275+
{
276+
name: "filePath is a parent",
277+
localDirectory: "/tmp/foo",
278+
filePath: "/tmp",
279+
wantIsSub: false,
280+
},
281+
{
282+
name: "directory traversal attempt",
283+
localDirectory: "/tmp/foo",
284+
filePath: "/tmp/foo/../bar", // resolves to /tmp/bar
285+
wantIsSub: false,
286+
},
287+
{
288+
name: "deeper directory traversal attempt",
289+
localDirectory: "/tmp/foo",
290+
filePath: "/tmp/foo/bar/../../baz", // resolves to /tmp/baz
291+
wantIsSub: false,
292+
},
293+
{
294+
name: "relative paths - valid",
295+
localDirectory: tempDir,
296+
filePath: filepath.Join(tempDir, "bar"),
297+
wantIsSub: true,
298+
},
299+
{
300+
name: "relative paths - traversal",
301+
localDirectory: tempDir,
302+
filePath: filepath.Join(tempDir, "..", "bar"),
303+
wantIsSub: false,
304+
},
305+
{
306+
name: "relative path is just ..",
307+
localDirectory: "foo",
308+
filePath: "foo" + sep + ".." + sep + "..",
309+
wantIsSub: false,
310+
},
311+
{
312+
name: "IsSubPath returns error when base dir is changed",
313+
localDirectory: "foo",
314+
filePath: "bar",
315+
wantErrMsg: "no such file or directory",
316+
},
317+
}
318+
319+
for _, tc := range testCases {
320+
t.Run(tc.name, func(t *testing.T) {
321+
origWd, _ := os.Getwd()
322+
t.Cleanup(func() {
323+
os.Chdir(origWd)
324+
})
325+
wantErr := (tc.wantErrMsg != "")
326+
// induce filepath.Abs() error
327+
if wantErr {
328+
dir, _ := os.MkdirTemp("", "")
329+
os.Chdir(dir)
330+
os.RemoveAll(dir)
331+
}
332+
isSub, err := isSubPath(tc.localDirectory, tc.filePath)
333+
334+
if (err != nil) != wantErr {
335+
t.Fatalf("isSubPath() error = %v, wantErr %v", err, tc.wantErrMsg)
336+
}
337+
if wantErr && !strings.Contains(err.Error(), tc.wantErrMsg) {
338+
t.Errorf("isSubPath() error = %s, want err containing %s", err.Error(), tc.wantErrMsg)
339+
return
340+
}
341+
if isSub != tc.wantIsSub {
342+
t.Errorf("isSubPath() = %v, want %v", isSub, tc.wantIsSub)
343+
}
344+
})
345+
}
346+
}
347+
235348
func TestCalculateRange(t *testing.T) {
236349
t.Parallel()
237350
for _, test := range []struct {

0 commit comments

Comments
 (0)