Skip to content

Commit 418b0f6

Browse files
authored
Merge pull request #47 from robzolkos/fix-attachment-path-traversal
Prevent path traversal in attachment downloads
2 parents 8b794d5 + 53c091b commit 418b0f6

2 files changed

Lines changed: 84 additions & 1 deletion

File tree

internal/commands/attachment.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package commands
22

33
import (
4+
"path/filepath"
45
"regexp"
56
"strconv"
67

@@ -112,7 +113,8 @@ Use 'fizzy card attachments show CARD_NUMBER' to see available attachments and t
112113
// Download the files
113114
var results []map[string]interface{}
114115
for _, attachment := range toDownload {
115-
outputPath := attachment.Filename
116+
// Sanitize filename to prevent path traversal attacks
117+
outputPath := filepath.Base(attachment.Filename)
116118
// If downloading single file with custom output name
117119
if len(toDownload) == 1 && attachmentDownloadOutput != "" {
118120
outputPath = attachmentDownloadOutput

internal/commands/attachment_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,3 +415,84 @@ func stringContains(s, substr string) bool {
415415
}
416416
return false
417417
}
418+
419+
func TestAttachmentDownloadSanitizesFilename(t *testing.T) {
420+
tests := []struct {
421+
name string
422+
maliciousFilename string
423+
expectedOutputPath string
424+
}{
425+
{
426+
name: "path traversal with ../",
427+
maliciousFilename: "../../../etc/passwd",
428+
expectedOutputPath: "passwd",
429+
},
430+
{
431+
name: "path traversal with subdirectory",
432+
maliciousFilename: "../../.bashrc",
433+
expectedOutputPath: ".bashrc",
434+
},
435+
{
436+
name: "absolute path attempt",
437+
maliciousFilename: "/etc/shadow",
438+
expectedOutputPath: "shadow",
439+
},
440+
{
441+
name: "normal filename unchanged",
442+
maliciousFilename: "safe-file.png",
443+
expectedOutputPath: "safe-file.png",
444+
},
445+
{
446+
name: "filename with spaces",
447+
maliciousFilename: "my document.pdf",
448+
expectedOutputPath: "my document.pdf",
449+
},
450+
{
451+
name: "deeply nested traversal",
452+
maliciousFilename: "../../../../../../../../tmp/malware.sh",
453+
expectedOutputPath: "malware.sh",
454+
},
455+
}
456+
457+
for _, tt := range tests {
458+
t.Run(tt.name, func(t *testing.T) {
459+
cardData := map[string]interface{}{
460+
"id": "card-id",
461+
"number": 241,
462+
"description_html": `<action-text-attachment sgid="test-sgid" content-type="image/png" filename="` + tt.maliciousFilename + `" filesize="1000">
463+
<a href="/blobs/blob/file?disposition=attachment">Download</a>
464+
</action-text-attachment>`,
465+
}
466+
467+
mock := NewMockClient().WithGetData(cardData)
468+
result := SetTestMode(mock)
469+
SetTestConfig("test-token", "test-account", "https://api.test.com")
470+
defer ResetTestMode()
471+
472+
rootCmd.SetArgs([]string{"card", "attachments", "download", "241", "1"})
473+
474+
RunTestCommand(func() {
475+
_ = rootCmd.Execute()
476+
})
477+
478+
if result.Response == nil {
479+
t.Fatal("expected response, got nil")
480+
}
481+
482+
if !result.Response.Success {
483+
t.Errorf("expected success, got error: %v", result.Response)
484+
return
485+
}
486+
487+
// Verify the output path was sanitized
488+
if len(mock.DownloadFileCalls) != 1 {
489+
t.Fatalf("expected 1 download call, got %d", len(mock.DownloadFileCalls))
490+
}
491+
492+
actualOutputPath := mock.DownloadFileCalls[0].DestPath
493+
if actualOutputPath != tt.expectedOutputPath {
494+
t.Errorf("expected sanitized output path %q, got %q", tt.expectedOutputPath, actualOutputPath)
495+
}
496+
})
497+
}
498+
}

0 commit comments

Comments
 (0)