summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-13 10:27:15 +0300
committerPaul Buetow <paul@buetow.org>2026-05-13 10:27:15 +0300
commita21c653c9939ac82b181709dc745f017fb3b8a8a (patch)
tree9aac7254da11fddb66895bc7b141ba8618e5d69f /internal
parent62104fbcabf811b6cd31db15f0f72db1f9d3c6e6 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/eventstream/export.go21
-rw-r--r--internal/tui/eventstream/export_test.go73
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 {