From a21c653c9939ac82b181709dc745f017fb3b8a8a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 13 May 2026 10:27:15 +0300 Subject: fix: prevent path traversal in TUI stream CSV export filename User-supplied filenames are now sanitised through filepath.Base before being joined with exportDir, so inputs like "../../etc/passwd" can no longer write files outside the intended export directory. Pure directory references ("..") are rejected outright. Two new tests cover both the unit-level sanitisation and the end-to-end exportRowsToCSV path. Co-Authored-By: Claude Sonnet 4.6 --- internal/tui/eventstream/export.go | 21 ++++++++-- internal/tui/eventstream/export_test.go | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/internal/tui/eventstream/export.go b/internal/tui/eventstream/export.go index 155a551..1aa4313 100644 --- a/internal/tui/eventstream/export.go +++ b/internal/tui/eventstream/export.go @@ -192,15 +192,30 @@ func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, er return absPath, nil } +// ensureCSVFilename validates and normalises a user-supplied export filename. +// It strips any directory components (preventing path traversal outside +// exportDir) and rejects names that resolve to "." or "..". A ".csv" +// extension is appended when the caller omits it. func ensureCSVFilename(name string) (string, error) { clean := strings.TrimSpace(name) if clean == "" { return "", errors.New("filename cannot be empty") } - if strings.HasSuffix(strings.ToLower(clean), ".csv") { - return clean, nil + + // Strip all directory components so that inputs such as + // "../../etc/passwd" or "/absolute/path.csv" cannot escape exportDir. + base := filepath.Base(clean) + + // filepath.Base returns "." for empty/dot inputs and ".." for a raw ".." + // component — both are unusable as a plain filename. + if base == "." || base == ".." { + return "", errors.New("filename must not be a directory reference") + } + + if strings.HasSuffix(strings.ToLower(base), ".csv") { + return base, nil } - return clean + ".csv", nil + return base + ".csv", nil } // ExportSnapshotToCSV exports a fresh filtered snapshot from the current source diff --git a/internal/tui/eventstream/export_test.go b/internal/tui/eventstream/export_test.go index 6bebe6b..f398fce 100644 --- a/internal/tui/eventstream/export_test.go +++ b/internal/tui/eventstream/export_test.go @@ -123,6 +123,79 @@ func TestResolveEditorCommandDoubleQuotedPathWithArgs(t *testing.T) { } } +// TestEnsureCSVFilenamePathTraversal verifies that path traversal attempts are +// rejected or stripped so that the resulting filename stays within exportDir. +func TestEnsureCSVFilenamePathTraversal(t *testing.T) { + cases := []struct { + input string + want string // empty string means an error is expected + wantErr bool + }{ + // Normal names — should pass through unchanged (with .csv if needed). + {"report", "report.csv", false}, + {"report.csv", "report.csv", false}, + {"Report.CSV", "Report.CSV", false}, + // Directory separators must be stripped — only the base name survives. + {"../../etc/passwd", "passwd.csv", false}, + {"../secret", "secret.csv", false}, + {"subdir/file.csv", "file.csv", false}, + {"/absolute/path.csv", "path.csv", false}, + // Pure directory references must be rejected. + {"..", "", true}, + {"some/../..", "", true}, + // Empty / whitespace-only must be rejected. + {"", "", true}, + {" ", "", true}, + } + + for _, tc := range cases { + got, err := ensureCSVFilename(tc.input) + if tc.wantErr { + if err == nil { + t.Errorf("ensureCSVFilename(%q): expected error, got %q", tc.input, got) + } + continue + } + if err != nil { + t.Errorf("ensureCSVFilename(%q): unexpected error: %v", tc.input, err) + continue + } + if got != tc.want { + t.Errorf("ensureCSVFilename(%q): got %q, want %q", tc.input, got, tc.want) + } + } +} + +// TestExportRowsToCSVPathTraversal verifies that exportRowsToCSV writes the +// output file inside exportDir even when the caller passes a path-traversal +// filename. +func TestExportRowsToCSVPathTraversal(t *testing.T) { + exportDir := t.TempDir() + outside := t.TempDir() + + // Craft a filename that would escape exportDir without sanitisation. + traversal := "../" + outside[len(outside)-1:] // relative path targeting outside dir + + // Use a clearly recognisable traversal pattern. + maliciousName := "../../escape.csv" + + path, err := exportRowsToCSV(nil, exportDir, maliciousName) + if err != nil { + t.Fatalf("exportRowsToCSV returned unexpected error: %v", err) + } + + // The written file must live inside exportDir, not outside it. + rel, err := filepath.Rel(exportDir, path) + if err != nil { + t.Fatalf("filepath.Rel: %v", err) + } + if len(rel) >= 2 && rel[:2] == ".." { + t.Errorf("output path %q escapes exportDir %q (rel=%q)", path, exportDir, rel) + } + + _ = traversal // silence unused-variable warning +} + // TestShellSplitVariousCases covers the tokenizer with a table-driven approach. func TestShellSplitVariousCases(t *testing.T) { cases := []struct { -- cgit v1.2.3