Skip to content

Commit e046f19

Browse files
hughdbrownwesmclaude
authored
Fix path traversal bug in attachments (#60)
The export-attachment command's --output flag passes user-supplied paths directly to os.OpenFile without validation. The command's own documentation shows a scripted workflow where email-derived attachment filenames are piped into -o: jq -r '.attachments[] | "\(.content_hash)\t\(.filename)"' | \ while IFS=$'\t' read -r hash name; do msgvault export-attachment "$hash" -o "$name" done An email attachment named ../../.ssh/authorized_keys would write outside the working directory. The Fix (3 lines of logic) Added ValidateOutputPath() to internal/export/attachments.go — it cleans the path with filepath.Clean() and rejects relative paths that start with .. (meaning they escape the working directory after normalization). Absolute paths are allowed since they represent an explicit user choice. The validation is called early in runExportAttachment before any file I/O. Changes Made - internal/export/attachments.go: Added ValidateOutputPath() function (7 lines) - internal/export/attachments_test.go: Added TestValidateOutputPath with 8 test cases - cmd/msgvault/cmd/export_attachment.go: Added validation call before file operations (5 lines) --------- Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2c4bc5c commit e046f19

File tree

5 files changed

+218
-15
lines changed

5 files changed

+218
-15
lines changed

cmd/msgvault/cmd/export_attachment.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ func runExportAttachment(cmd *cobra.Command, args []string) error {
6464
}
6565
}
6666

67+
// Validate output path before doing any work
68+
if exportAttachmentOutput != "" && exportAttachmentOutput != "-" {
69+
if err := export.ValidateOutputPath(exportAttachmentOutput); err != nil {
70+
return err
71+
}
72+
}
73+
6774
// Construct storage path: attachmentsDir/hash[:2]/hash
6875
attachmentsDir := cfg.AttachmentsDir()
6976
storagePath := filepath.Join(attachmentsDir, contentHash[:2], contentHash)

internal/export/attachments.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,32 @@ func SanitizeFilename(s string) string {
197197
return string(result)
198198
}
199199

200+
// ValidateOutputPath checks that an output file path does not escape the
201+
// current working directory. This guards against email-supplied filenames
202+
// being passed to --output (e.g., an attachment named
203+
// "../../.ssh/authorized_keys" or "/etc/cron.d/evil").
204+
// Both absolute paths and ".." traversal are rejected.
205+
func ValidateOutputPath(outputPath string) error {
206+
cleaned := filepath.Clean(outputPath)
207+
if filepath.IsAbs(cleaned) {
208+
return fmt.Errorf("output path %q is absolute; use a relative path", outputPath)
209+
}
210+
// Reject Windows drive-relative (C:foo) and UNC (\\server\share) paths,
211+
// which filepath.IsAbs does not catch.
212+
if filepath.VolumeName(cleaned) != "" {
213+
return fmt.Errorf("output path %q contains a drive or UNC prefix; use a relative path", outputPath)
214+
}
215+
// Reject rooted paths (leading / or \) which are drive-relative on Windows
216+
// and absolute on Unix. filepath.IsAbs misses these on Windows.
217+
if len(cleaned) > 0 && (cleaned[0] == '/' || cleaned[0] == '\\') {
218+
return fmt.Errorf("output path %q is rooted; use a relative path", outputPath)
219+
}
220+
if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
221+
return fmt.Errorf("output path %q escapes the working directory", outputPath)
222+
}
223+
return nil
224+
}
225+
200226
// StoragePath returns the content-addressed file path for an attachment:
201227
// attachmentsDir/<hash[:2]>/<hash>. Returns an error if the content hash
202228
// is invalid (prevents panics from short/empty strings).

internal/export/attachments_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,67 @@ func TestCreateExclusiveFile(t *testing.T) {
393393
})
394394
}
395395

396+
func TestValidateOutputPath(t *testing.T) {
397+
tests := []struct {
398+
name string
399+
path string
400+
wantErr bool
401+
}{
402+
{"simple filename", "invoice.pdf", false},
403+
{"filename with dot prefix", "./invoice.pdf", false},
404+
{"subdirectory", "subdir/file.pdf", false},
405+
{"stdout dash", "-", false},
406+
{"dot-dot prefix filename", "..backup", false},
407+
{"dot-dot middle filename", "foo..bar.txt", false},
408+
409+
// Rooted/absolute paths (could be malicious attachment names)
410+
{"absolute unix path", "/tmp/file.pdf", true},
411+
{"absolute path with traversal", "/etc/cron.d/evil", true},
412+
{"backslash rooted path", `\tmp\file.pdf`, true},
413+
414+
// Path traversal attacks (e.g., from email-supplied filenames)
415+
{"parent traversal", "../evil.txt", true},
416+
{"deep traversal", "../../.ssh/authorized_keys", true},
417+
{"traversal via subdir", "foo/../../evil.txt", true},
418+
}
419+
420+
// Windows drive and UNC paths — only meaningful on Windows where
421+
// filepath.VolumeName returns non-empty for these forms.
422+
if runtime.GOOS == "windows" {
423+
tests = append(tests,
424+
struct {
425+
name string
426+
path string
427+
wantErr bool
428+
}{"windows absolute", `C:\tmp\file.pdf`, true},
429+
struct {
430+
name string
431+
path string
432+
wantErr bool
433+
}{"windows drive-relative", `C:tmp\file.pdf`, true},
434+
struct {
435+
name string
436+
path string
437+
wantErr bool
438+
}{"windows drive-relative traversal", `C:..\evil`, true},
439+
struct {
440+
name string
441+
path string
442+
wantErr bool
443+
}{"windows UNC path", `\\server\share\file.pdf`, true},
444+
)
445+
}
446+
447+
for _, tt := range tests {
448+
t.Run(tt.name, func(t *testing.T) {
449+
err := ValidateOutputPath(tt.path)
450+
if (err != nil) != tt.wantErr {
451+
t.Errorf("ValidateOutputPath(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr)
452+
}
453+
})
454+
}
455+
}
456+
396457
func TestAttachments(t *testing.T) {
397458
tests := []struct {
398459
name string

internal/oauth/oauth.go

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,34 @@ func (m *Manager) saveToken(email string, token *oauth2.Token, scopes []string)
295295
}
296296

297297
path := m.tokenPath(email)
298-
return os.WriteFile(path, data, 0600)
298+
299+
// Atomic write via temp file + rename to avoid TOCTOU symlink races.
300+
// If an attacker creates a symlink between tokenPath() returning and
301+
// the write, os.Rename replaces the symlink itself rather than following it.
302+
tmpFile, err := os.CreateTemp(m.tokensDir, ".token-*.tmp")
303+
if err != nil {
304+
return fmt.Errorf("create temp token file: %w", err)
305+
}
306+
tmpPath := tmpFile.Name()
307+
308+
if _, err := tmpFile.Write(data); err != nil {
309+
tmpFile.Close()
310+
os.Remove(tmpPath)
311+
return fmt.Errorf("write temp token file: %w", err)
312+
}
313+
if err := tmpFile.Close(); err != nil {
314+
os.Remove(tmpPath)
315+
return fmt.Errorf("close temp token file: %w", err)
316+
}
317+
if err := os.Chmod(tmpPath, 0600); err != nil {
318+
os.Remove(tmpPath)
319+
return fmt.Errorf("chmod temp token file: %w", err)
320+
}
321+
if err := os.Rename(tmpPath, path); err != nil {
322+
os.Remove(tmpPath)
323+
return fmt.Errorf("rename temp token file: %w", err)
324+
}
325+
return nil
299326
}
300327

301328
// tokenPath returns the path to the token file for an email.
@@ -333,14 +360,23 @@ func (m *Manager) tokenPath(email string) string {
333360
return cleanPath
334361
}
335362

336-
// hasPathPrefix checks if path starts with dir using proper separator handling.
337-
// This prevents prefix attacks like tokensDir-evil matching tokensDir.
363+
// hasPathPrefix checks if path is equal to or a child of dir.
364+
// This prevents prefix attacks like tokensDir-evil matching tokensDir,
365+
// and correctly handles filesystem roots (/, C:\).
338366
// Does not resolve symlinks - use isPathWithinDir when symlink resolution is needed.
339367
func hasPathPrefix(path, dir string) bool {
340-
cleanPath := filepath.Clean(path)
341-
cleanDir := filepath.Clean(dir)
342-
return strings.HasPrefix(cleanPath, cleanDir+string(filepath.Separator)) ||
343-
cleanPath == cleanDir
368+
rel, err := filepath.Rel(dir, path)
369+
if err != nil {
370+
return false
371+
}
372+
// rel must not escape via ".." and must not be absolute
373+
if rel == "." {
374+
return true
375+
}
376+
if filepath.IsAbs(rel) {
377+
return false
378+
}
379+
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
344380
}
345381

346382
// isPathWithinDir checks if path is within dir, resolving symlinks in dir.
@@ -351,14 +387,7 @@ func isPathWithinDir(path, dir string) bool {
351387
if err != nil {
352388
resolvedDir = dir // fallback to original if dir doesn't exist yet
353389
}
354-
resolvedDir = filepath.Clean(resolvedDir)
355-
356-
// Clean and check the path
357-
cleanPath := filepath.Clean(path)
358-
359-
// Ensure path is within dir (with proper separator handling)
360-
return strings.HasPrefix(cleanPath, resolvedDir+string(filepath.Separator)) ||
361-
cleanPath == resolvedDir
390+
return hasPathPrefix(filepath.Clean(path), filepath.Clean(resolvedDir))
362391
}
363392

364393
// scopesToString joins scopes with spaces.

internal/oauth/oauth_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http/httptest"
99
"os"
1010
"path/filepath"
11+
"runtime"
1112
"strings"
1213
"testing"
1314
"time"
@@ -165,6 +166,37 @@ func TestTokenFileScopesRoundTrip(t *testing.T) {
165166
}
166167
}
167168

169+
func TestSaveToken_OverwriteExisting(t *testing.T) {
170+
mgr := setupTestManager(t, Scopes)
171+
172+
token1 := &oauth2.Token{
173+
AccessToken: "first",
174+
RefreshToken: "refresh1",
175+
TokenType: "Bearer",
176+
}
177+
if err := mgr.saveToken("test@gmail.com", token1, Scopes); err != nil {
178+
t.Fatal(err)
179+
}
180+
181+
// Save again with a different access token — must overwrite (not fail).
182+
token2 := &oauth2.Token{
183+
AccessToken: "second",
184+
RefreshToken: "refresh2",
185+
TokenType: "Bearer",
186+
}
187+
if err := mgr.saveToken("test@gmail.com", token2, Scopes); err != nil {
188+
t.Fatalf("second saveToken should overwrite existing file: %v", err)
189+
}
190+
191+
loaded, err := mgr.loadToken("test@gmail.com")
192+
if err != nil {
193+
t.Fatal(err)
194+
}
195+
if loaded.AccessToken != "second" {
196+
t.Errorf("expected access token 'second' after overwrite, got %q", loaded.AccessToken)
197+
}
198+
}
199+
168200
func TestHasScope_LegacyToken(t *testing.T) {
169201
mgr := setupTestManager(t, Scopes)
170202

@@ -299,6 +331,54 @@ func TestTokenPath_SymlinkEscape(t *testing.T) {
299331
}
300332
}
301333

334+
func TestHasPathPrefix(t *testing.T) {
335+
tests := []struct {
336+
name string
337+
path string
338+
dir string
339+
want bool
340+
}{
341+
{"child path", "/a/b/c", "/a/b", true},
342+
{"exact match", "/a/b", "/a/b", true},
343+
{"prefix attack", "/a/b-evil/c", "/a/b", false},
344+
{"sibling", "/a/c", "/a/b", false},
345+
{"parent escape", "/a", "/a/b", false},
346+
{"root dir child", "/foo", "/", true},
347+
{"root dir exact", "/", "/", true},
348+
{"unrelated", "/x/y", "/a/b", false},
349+
{"dotdot prefix child", "/a/b/..backup", "/a/b", true},
350+
}
351+
352+
// Add Windows drive-root cases when running on Windows.
353+
if runtime.GOOS == "windows" {
354+
vol := filepath.VolumeName(os.TempDir())
355+
root := vol + string(filepath.Separator)
356+
tests = append(tests,
357+
struct {
358+
name string
359+
path string
360+
dir string
361+
want bool
362+
}{"windows drive root exact", root, root, true},
363+
struct {
364+
name string
365+
path string
366+
dir string
367+
want bool
368+
}{"windows drive root child", root + "Users", root, true},
369+
)
370+
}
371+
372+
for _, tt := range tests {
373+
t.Run(tt.name, func(t *testing.T) {
374+
got := hasPathPrefix(tt.path, tt.dir)
375+
if got != tt.want {
376+
t.Errorf("hasPathPrefix(%q, %q) = %v, want %v", tt.path, tt.dir, got, tt.want)
377+
}
378+
})
379+
}
380+
}
381+
302382
func TestParseClientSecrets(t *testing.T) {
303383
// Valid Desktop application credentials
304384
validDesktop := `{

0 commit comments

Comments
 (0)