diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-06-01 10:56:43 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-06-01 10:56:43 -0700 |
| commit | 30db5839a93936f8b69d0d5d005bbe03197722c1 (patch) | |
| tree | 81ee122b064d5800bf924f7b1c5c2d56543a341c | |
| parent | c42c103ddb66028a085fb452102f73f8b27dcdcb (diff) | |
Normalize folder upload item paths and consolidate path handling
Anchor the path returned by folderUpload.FormattedPath relative to the
upload root so item paths are resolved consistently, matching the
behavior of ReadPath. Resolve each destination path once per item in
UploadFolderHandler and use path.Join in place of manual string
concatenation.
Add test coverage for FormattedPath normalization.
| -rw-r--r-- | hotline/file_transfer.go | 21 | ||||
| -rw-r--r-- | hotline/file_transfer_test.go | 62 |
2 files changed, 75 insertions, 8 deletions
diff --git a/hotline/file_transfer.go b/hotline/file_transfer.go index 86e10eb..8670439 100644 --- a/hotline/file_transfer.go +++ b/hotline/file_transfer.go @@ -209,7 +209,9 @@ func (fu *folderUpload) FormattedPath() string { } } - return path.Join(pathSegments...) + // Anchor at "/" so any ".." segments are collapsed and cannot escape the upload + // root (mirrors ReadPath). Strip the leading separator to keep the path relative. + return strings.TrimPrefix(path.Join("/", path.Join(pathSegments...)), "/") } type FileHeader struct { @@ -561,9 +563,12 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT return err } + // Resolve the item path once. FormattedPath is sanitized to stay within fullPath. + itemPath := path.Join(fullPath, fu.FormattedPath()) + if fu.IsFolder == [2]byte{0, 1} { - if _, err := os.Stat(path.Join(fullPath, fu.FormattedPath())); os.IsNotExist(err) { - if err := os.Mkdir(path.Join(fullPath, fu.FormattedPath()), 0777); err != nil { + if _, err := os.Stat(itemPath); os.IsNotExist(err) { + if err := os.Mkdir(itemPath, 0777); err != nil { return err } } @@ -576,7 +581,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT nextAction := DlFldrActionSendFile // Check if we have the full file already. If so, send dlFldrAction_NextFile to client to skip. - _, err := os.Stat(path.Join(fullPath, fu.FormattedPath())) + _, err := os.Stat(itemPath) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } @@ -585,7 +590,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT } // Check if we have a partial file already. If so, send dlFldrAction_ResumeFile to client to resume upload. - incompleteFile, err := os.Stat(path.Join(fullPath, fu.FormattedPath()+IncompleteFileSuffix)) + incompleteFile, err := os.Stat(itemPath + IncompleteFileSuffix) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } @@ -604,7 +609,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT offset := make([]byte, 4) binary.BigEndian.PutUint32(offset, uint32(incompleteFile.Size())) - file, err := os.OpenFile(fullPath+"/"+fu.FormattedPath()+IncompleteFileSuffix, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + file, err := os.OpenFile(itemPath+IncompleteFileSuffix, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return err } @@ -628,7 +633,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT rLogger.Error("Error receiving file", "err", err) } - err = os.Rename(fullPath+"/"+fu.FormattedPath()+".incomplete", fullPath+"/"+fu.FormattedPath()) + err = os.Rename(itemPath+IncompleteFileSuffix, itemPath) if err != nil { return err } @@ -638,7 +643,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT return err } - filePath := path.Join(fullPath, fu.FormattedPath()) + filePath := itemPath hlFile, err := NewFile(fileStore, filePath, 0) if err != nil { diff --git a/hotline/file_transfer_test.go b/hotline/file_transfer_test.go index a5c4afe..d427352 100644 --- a/hotline/file_transfer_test.go +++ b/hotline/file_transfer_test.go @@ -255,6 +255,68 @@ func Test_folderUpload_FormattedPath(t *testing.T) { }, want: "test@$%&", }, + { + // Traversal attempt: leading ".." segments must be collapsed and cannot escape the upload root. + name: "traversal with parent dir segments", + pathItemCount: [2]byte{0x00, 0x04}, + fileNamePath: []byte{ + 0x00, 0x00, // path separator + 0x02, // segment length + 0x2e, 0x2e, // ".." + 0x00, 0x00, // path separator + 0x02, // segment length + 0x2e, 0x2e, // ".." + 0x00, 0x00, // path separator + 0x03, // segment length + 0x65, 0x74, 0x63, // "etc" + 0x00, 0x00, // path separator + 0x06, // segment length + 0x70, 0x61, 0x73, 0x73, 0x77, 0x64, // "passwd" + }, + want: "etc/passwd", + }, + { + // Interior ".." segments resolve away without escaping. + name: "traversal with interior parent dir segments", + pathItemCount: [2]byte{0x00, 0x04}, + fileNamePath: []byte{ + 0x00, 0x00, // path separator + 0x03, // segment length + 0x66, 0x6f, 0x6f, // "foo" + 0x00, 0x00, // path separator + 0x02, // segment length + 0x2e, 0x2e, // ".." + 0x00, 0x00, // path separator + 0x02, // segment length + 0x2e, 0x2e, // ".." + 0x00, 0x00, // path separator + 0x03, // segment length + 0x62, 0x61, 0x72, // "bar" + }, + want: "bar", + }, + { + // A lone ".." segment collapses to the upload root (empty relative path). + name: "single parent dir segment", + pathItemCount: [2]byte{0x00, 0x01}, + fileNamePath: []byte{ + 0x00, 0x00, // path separator + 0x02, // segment length + 0x2e, 0x2e, // ".." + }, + want: "", + }, + { + // A single segment whose raw bytes embed separators and "..". + name: "segment containing embedded separators", + pathItemCount: [2]byte{0x00, 0x01}, + fileNamePath: []byte{ + 0x00, 0x00, // path separator + 0x09, // segment length + 0x2e, 0x2e, 0x2f, 0x2e, 0x2e, 0x2f, 0x65, 0x74, 0x63, // "../../etc" (9 bytes) + }, + want: "etc", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { |