Skip to content
47 changes: 26 additions & 21 deletions shortcuts/mail/mail_watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,35 +192,40 @@
msgFormat := runtime.Str("msg-format")
outputDir := runtime.Str("output-dir")
if outputDir != "" {
if outputDir == "~" || strings.HasPrefix(outputDir, "~/") {
home, err := vfs.UserHomeDir()
if err != nil {
return fmt.Errorf("cannot expand ~: %w", err)
}
if outputDir == "~" {
outputDir = home
} else {
outputDir = filepath.Join(home, outputDir[2:])
}
} else if filepath.IsAbs(outputDir) {
outputDir = filepath.Clean(outputDir)
} else {
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return err
}
outputDir = safePath
// Reject all tilde-prefixed paths — SafeOutputPath treats "~/x" as a
// literal relative path (creating a directory named "~"), which is
// confusing. This also covers ~user/path forms.
if strings.HasPrefix(outputDir, "~") {
return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
}
// Resolve symlinks on the output directory so all writes use the real
// filesystem path. This prevents a symlink from redirecting writes to
// an unintended location (TOCTOU mitigation).
// Enforce CWD containment: reject absolute paths, path traversal,
// and symlink escapes. SafeOutputPath returns a resolved absolute path
// under CWD, preventing writes to arbitrary system directories.
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return err
}
outputDir = safePath
if err := vfs.MkdirAll(outputDir, 0700); err != nil {
return fmt.Errorf("cannot create output directory %q: %w", outputDir, err)
}
// TOCTOU mitigation: after MkdirAll, resolve symlinks on the now-existing
// directory and re-verify it is still under CWD. This prevents an attacker
// from replacing the newly created directory with a symlink between mkdir
// and the first write.
resolved, err := filepath.EvalSymlinks(outputDir)
if err != nil {
return fmt.Errorf("cannot resolve output directory: %w", err)
}
cwd, err := os.Getwd()

Check failure on line 220 in shortcuts/mail/mail_watch.go

View workflow job for this annotation

GitHub Actions / golangci-lint

use of `os.Getwd` forbidden because "use the corresponding vfs.Xxx() from internal/vfs" (forbidigo)
if err != nil {
return fmt.Errorf("cannot determine working directory: %w", err)
}
canonicalCwd, _ := filepath.EvalSymlinks(cwd)

Check failure on line 224 in shortcuts/mail/mail_watch.go

View workflow job for this annotation

GitHub Actions / golangci-lint

use of `filepath.EvalSymlinks` forbidden because "These filepath functions access the filesystem directly. internal/: use vfs helpers or localfileio path validation. shortcuts/: use runtime.ValidatePath() or runtime.FileIO()." (forbidigo)
rel, err := filepath.Rel(canonicalCwd, resolved)
if err != nil || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
return output.ErrValidation("--output-dir %q resolves outside the current working directory after mkdir (possible symlink attack)", outputDir)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
outputDir = resolved
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
labelIDsInput := runtime.Str("label-ids")
Expand Down
Loading