From 0369c9a48f86b8fcca551e00f827a9fd3ad37fb9 Mon Sep 17 00:00:00 2001 From: Daniel Purnomo Date: Tue, 21 Apr 2026 14:14:11 +0200 Subject: [PATCH] fix: sanitize attachment filename to prevent path traversal (#743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What? Added sanitizeFilename() function and applied it to the attachment download path in main.go. The function: Strips path components via filepath.Base() Replaces remaining path separators (/, \) and .. sequences with _ Rejects hidden files (names starting with .) and empty names, falling back to "attachment" Why? Fixes #725 Fixes #642 Attachment filenames come from untrusted email headers. Without sanitization, a malicious filename like ../../../etc/passwd passed to filepath.Join(downloadsPath, candidate) could write files outside the Downloads directory. This is a path traversal vulnerability that could overwrite arbitrary user files. Testing 16 table-driven test cases covering Unix traversal, Windows traversal, hidden files, empty names, unicode, absolute paths, and chained traversal Additional test verifying no sanitized output ever contains path separators go build . — compiles clean All tests pass --- main.go | 21 +++++++++++++++++- main_test.go | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 main_test.go diff --git a/main.go b/main.go index 548481ead71caa02f5754ae9b01bf9c065145171..4eda753517c778543551d7ca2d80b18e697e1d70 100644 --- a/main.go +++ b/main.go @@ -2568,6 +2568,25 @@ func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, } } +// sanitizeFilename prevents path traversal attacks on attachment downloads. +// Email attachment filenames come from untrusted email headers and could +// contain path separators or ".." sequences to escape the Downloads directory. +func sanitizeFilename(name string) string { + // Normalize backslashes to forward slashes so filepath.Base works + // correctly on all platforms (Linux doesn't treat \ as a separator) + name = strings.ReplaceAll(name, "\\", "/") + // Strip any path components, keep only the base filename + name = filepath.Base(name) + // Replace any remaining path separators (defensive) + name = strings.ReplaceAll(name, "/", "_") + name = strings.ReplaceAll(name, "..", "_") + // Reject hidden files and empty names + if name == "" || name == "." || strings.HasPrefix(name, ".") { + name = "attachment" + } + return name +} + func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd { return func() tea.Msg { // Download and decode the attachment using encoding provided in msg.Encoding. @@ -2600,7 +2619,7 @@ func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.Download // Save the attachment using an exclusive create so we never overwrite an existing file. // If the filename already exists, append \" (n)\" before the extension. - origName := msg.Filename + origName := sanitizeFilename(msg.Filename) ext := filepath.Ext(origName) base := strings.TrimSuffix(origName, ext) candidate := origName diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c98cc4bc886982d1cb0ab8799dd07b1b8f8367f7 --- /dev/null +++ b/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "testing" +) + +func TestSanitizeFilename(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"normal filename", "document.pdf", "document.pdf"}, + {"filename with spaces", "my report.docx", "my report.docx"}, + {"unix path traversal", "../../../etc/passwd", "passwd"}, + {"windows path traversal", "..\\..\\..\\windows\\system32\\config", "config"}, + {"mixed traversal", "../secret/key.pem", "key.pem"}, + {"hidden file", ".bashrc", "attachment"}, + {"dot only", ".", "attachment"}, + {"dot dot", "..", "_"}, + {"empty string", "", "attachment"}, + {"absolute unix path", "/etc/shadow", "shadow"}, + {"absolute windows path", "C:\\Users\\secret.txt", "secret.txt"}, + {"double dot in middle", "file..name.txt", "file_name.txt"}, + {"multiple slashes", "path/to/file.txt", "file.txt"}, + {"null bytes removed", "file\x00name.txt", "file\x00name.txt"}, + {"unicode filename", "日本語.txt", "日本語.txt"}, + {"long traversal chain", "a/b/c/../../../d/e/f.txt", "f.txt"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeFilename(tt.input) + if got != tt.expected { + t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.expected) + } + // Verify the sanitized name never allows escaping the download directory + if got == "" { + t.Error("sanitizeFilename returned empty string") + } + if got == "." || got == ".." { + t.Error("sanitizeFilename returned a dangerous name: " + got) + } + }) + } +} + +func TestSanitizeFilenameNoPathSeparators(t *testing.T) { + // Ensure no sanitized output contains path separators + dangerous := []string{ + "a/b", "a\\b", "../a", "..\\a", + "/etc/passwd", "\\Windows\\System32", + "....//....//etc/passwd", + } + for _, input := range dangerous { + got := sanitizeFilename(input) + for _, ch := range got { + if ch == '/' || ch == '\\' { + t.Errorf("sanitizeFilename(%q) = %q contains path separator", input, got) + } + } + } +}