Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/event/consume.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -309,11 +310,15 @@ func preflightEventTypes(pf *preflightCtx) error {
)
}

// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ so that
// event output stays within the working directory.
func sanitizeOutputDir(dir string) (string, error) {
if strings.HasPrefix(dir, "~") {
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
}
if filepath.IsAbs(dir) {
return "", output.ErrValidation("%s %q: absolute paths are not permitted for --output-dir; use a relative path", errOutputDirUnsafe, dir)
}
safe, err := validate.SafeOutputPath(dir)
if err != nil {
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
Expand Down
15 changes: 11 additions & 4 deletions internal/client/response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,11 +400,18 @@ func TestSaveResponse_RejectsPathTraversal(t *testing.T) {
}
}

func TestSaveResponse_RejectsAbsolutePath(t *testing.T) {
func TestSaveResponse_AcceptsAbsolutePath(t *testing.T) {
f, err := os.CreateTemp("", "save-abs-*.bin")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })

resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "/tmp/evil.txt")
if err == nil {
t.Fatal("expected error for absolute path")
_, err = SaveResponse(&localfileio.LocalFileIO{}, resp, f.Name())
if err != nil {
t.Fatalf("SaveResponse to absolute path should succeed: %v", err)
}
}

Expand Down
7 changes: 3 additions & 4 deletions internal/cmdutil/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,10 @@ func TestResolveInput_AtFile_PathValidation(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
// Absolute paths are rejected by SafeInputPath; the error must surface
// as an invalid-path message, not a generic read failure.
_, err := ResolveInput("@/etc/passwd", nil, fio)
// Path traversal must surface as an invalid-path message, not a generic read failure.
_, err := ResolveInput("@../../etc/passwd", nil, fio)
if err == nil || !strings.Contains(err.Error(), "invalid file path") {
t.Errorf("expected path-validation error, got: %v", err)
t.Errorf("expected path-validation error for traversal, got: %v", err)
}
}

Expand Down
48 changes: 21 additions & 27 deletions internal/validate/path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
{"dot-dot mid path", "subdir/../../etc/passwd", true},
{"triple dot-dot", "../../../etc/shadow", true},

// ── GIVEN: absolute paths → THEN: rejected ──
{"absolute path unix", "/etc/passwd", true},
{"absolute path root", "/tmp/evil", true},
// ── GIVEN: absolute paths → THEN: allowed (OS enforces access) ──
{"absolute path unix", "/etc/passwd", false},
{"absolute path tmp", "/tmp/output.xlsx", false},

// ── GIVEN: control characters in path → THEN: rejected ──
{"null byte", "file\x00.txt", true},
Expand Down Expand Up @@ -187,7 +187,7 @@ func TestSafeLocalFlagPath(t *testing.T) {
{"https URL passes through", "--image", "https://example.com/a.jpg", "https://example.com/a.jpg", ""},
{"relative path accepted, returned unchanged", "--file", "photo.jpg", "photo.jpg", ""},
{"path traversal rejected", "--file", "../escape.txt", "", "--file"},
{"absolute path rejected", "--image", "/etc/passwd", "", "--image"},
{"absolute path accepted", "--image", "/etc/passwd", "/etc/passwd", ""},
} {
t.Run(tt.name, func(t *testing.T) {
got, err := SafeLocalFlagPath(tt.flag, tt.value)
Expand All @@ -207,7 +207,7 @@ func TestSafeLocalFlagPath(t *testing.T) {
}
}

func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
func TestSafeInputPath_AllowsAbsoluteTempPath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")
if err != nil {
Expand All @@ -216,22 +216,17 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
tmpPath := f.Name()
f.Close()
t.Cleanup(func() { os.Remove(tmpPath) })
tmpPath, _ = filepath.EvalSymlinks(tmpPath)

// WHEN: SafeUploadPath validates the absolute temp path
_, err = SafeInputPath(tmpPath)
// WHEN: SafeInputPath validates the absolute temp path
got, err := SafeInputPath(tmpPath)

// THEN: absolute paths are rejected even in temp dir
if err == nil {
t.Fatal("expected error for absolute temp path, got nil")
// THEN: absolute paths are accepted; OS permissions apply on actual read
if err != nil {
t.Fatalf("unexpected error for absolute temp path: %v", err)
}
}

func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
// GIVEN: an absolute path outside the temp directory
// WHEN / THEN: SafeUploadPath rejects it
_, err := SafeInputPath("/etc/passwd")
if err == nil {
t.Error("expected error for absolute non-temp path, got nil")
if got != tmpPath {
t.Errorf("got %q, want %q", got, tmpPath)
}
}

Expand All @@ -258,26 +253,25 @@ func TestSafeUploadPath_AcceptsRelativePath(t *testing.T) {
}
}

func TestSafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) {
// GIVEN: an absolute path

func TestSafeInputPath_ErrorMessageContainsFlagName(t *testing.T) {
// GIVEN: a relative path that escapes CWD via ..
// WHEN: SafeInputPath rejects it
_, err := SafeInputPath("/etc/passwd")
_, err := SafeInputPath("../../etc/passwd")

// THEN: error message mentions --file (not --output)
// THEN: error message mentions --file
if err == nil {
t.Fatal("expected error for absolute path")
t.Fatal("expected error for path-traversal input")
}
if !strings.Contains(err.Error(), "--file") {
t.Errorf("error should mention --file, got: %s", err.Error())
}

// WHEN: SafeOutputPath rejects it
_, err = SafeOutputPath("/etc/passwd")
_, err = SafeOutputPath("../../etc/passwd")

// THEN: error message mentions --output (not --file)
// THEN: error message mentions --output
if err == nil {
t.Fatal("expected error for absolute path")
t.Fatal("expected error for path-traversal output")
}
if !strings.Contains(err.Error(), "--output") {
t.Errorf("error should mention --output, got: %s", err.Error())
Expand Down
62 changes: 39 additions & 23 deletions internal/vfs/localfileio/localfileio_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,21 @@ func TestLocalFileIO_Open_RejectsTraversal(t *testing.T) {
}
}

func TestLocalFileIO_Open_RejectsAbsolutePath(t *testing.T) {
fio := &LocalFileIO{}
_, err := fio.Open("/etc/passwd")
if err == nil {
t.Error("expected error for absolute path")
func TestLocalFileIO_Open_AcceptsAbsolutePath(t *testing.T) {
f, err := os.CreateTemp("", "open-abs-*.txt")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
if err != nil && !strings.Contains(err.Error(), "relative path") {
t.Errorf("error should mention relative path, got: %v", err)
f.WriteString("hello")
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })

fio := &LocalFileIO{}
r, err := fio.Open(f.Name())
if err != nil {
t.Fatalf("Open absolute path should succeed: %v", err)
}
r.Close()
}

func TestLocalFileIO_Open_NonexistentFile(t *testing.T) {
Expand Down Expand Up @@ -204,11 +210,18 @@ func TestLocalFileIO_Save_RejectsTraversal(t *testing.T) {
}
}

func TestLocalFileIO_Save_RejectsAbsolutePath(t *testing.T) {
func TestLocalFileIO_Save_AcceptsAbsolutePath(t *testing.T) {
tmpFile, err := os.CreateTemp("", "save-abs-*.txt")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
tmpFile.Close()
t.Cleanup(func() { os.Remove(tmpFile.Name()) })

fio := &LocalFileIO{}
_, err := fio.Save("/tmp/evil.txt", fileio.SaveOptions{}, strings.NewReader("bad"))
if err == nil {
t.Error("expected error for absolute path in Save")
_, err = fio.Save(tmpFile.Name(), fileio.SaveOptions{}, strings.NewReader("content"))
if err != nil {
t.Errorf("Save to absolute path should succeed: %v", err)
}
}

Expand Down Expand Up @@ -242,11 +255,14 @@ func TestLocalFileIO_ResolvePath_RejectsTraversal(t *testing.T) {
}
}

func TestLocalFileIO_ResolvePath_RejectsAbsolute(t *testing.T) {
func TestLocalFileIO_ResolvePath_AcceptsAbsolute(t *testing.T) {
fio := &LocalFileIO{}
_, err := fio.ResolvePath("/etc/passwd")
if err == nil {
t.Error("expected error for absolute path in ResolvePath")
got, err := fio.ResolvePath("/etc/passwd")
if err != nil {
t.Fatalf("ResolvePath absolute path should succeed: %v", err)
}
if !filepath.IsAbs(got) {
t.Errorf("expected absolute path result, got %q", got)
}
}

Expand All @@ -259,25 +275,25 @@ func TestLocalFileIO_ErrorMessages_ContainCorrectFlagName(t *testing.T) {
fio := &LocalFileIO{}

// Open/Stat use SafeInputPath → errors should mention "--file"
_, err := fio.Open("/absolute/path")
_, err := fio.Open("../../traversal/path")
if err == nil || !strings.Contains(err.Error(), "--file") {
t.Errorf("Open absolute path error should mention --file, got: %v", err)
t.Errorf("Open traversal path error should mention --file, got: %v", err)
}

_, err = fio.Stat("/absolute/path")
_, err = fio.Stat("../../traversal/path")
if err == nil || !strings.Contains(err.Error(), "--file") {
t.Errorf("Stat absolute path error should mention --file, got: %v", err)
t.Errorf("Stat traversal path error should mention --file, got: %v", err)
}

// Save/ResolvePath use SafeOutputPath → errors should mention "--output"
_, err = fio.Save("/absolute/path", fileio.SaveOptions{}, strings.NewReader(""))
_, err = fio.Save("../../traversal/path", fileio.SaveOptions{}, strings.NewReader(""))
if err == nil || !strings.Contains(err.Error(), "--output") {
t.Errorf("Save absolute path error should mention --output, got: %v", err)
t.Errorf("Save traversal path error should mention --output, got: %v", err)
}

_, err = fio.ResolvePath("/absolute/path")
_, err = fio.ResolvePath("../../traversal/path")
if err == nil || !strings.Contains(err.Error(), "--output") {
t.Errorf("ResolvePath absolute path error should mention --output, got: %v", err)
t.Errorf("ResolvePath traversal path error should mention --output, got: %v", err)
}
}

Expand Down
9 changes: 8 additions & 1 deletion internal/vfs/localfileio/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,14 @@ func safePath(raw, flagName string) (string, error) {
path := filepath.Clean(raw)

if filepath.IsAbs(path) {
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
// Absolute paths are accepted without cwd restriction, matching
// common CLI conventions (gh, gcloud, kubectl cp). OS permissions
// still enforce access when the file is actually read or written.
resolved, err := resolveNearestAncestor(path)
if err != nil {
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
}
return resolved, nil
}

cwd, err := vfs.Getwd()
Expand Down
Loading
Loading