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
83 changes: 70 additions & 13 deletions pkg/tui/components/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ type Editor interface {
// InsertText inserts text at the current cursor position
InsertText(text string)
// AttachFile adds a file as an attachment and inserts @filepath into the editor
AttachFile(filePath string)
AttachFile(filePath string) error
Cleanup()
GetSize() (width, height int)
BannerHeight() int
Expand Down Expand Up @@ -690,7 +690,9 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
}
// Track file references when using @ completion (but not paste placeholders)
if e.currentCompletion != nil && e.currentCompletion.Trigger() == "@" && !strings.HasPrefix(msg.Value, "@paste-") {
e.addFileAttachment(msg.Value)
if err := e.addFileAttachment(msg.Value); err != nil {
slog.Warn("failed to add file attachment from completion", "value", msg.Value, "error", err)
}
}
e.clearSuggestion()
return e, nil
Expand Down Expand Up @@ -1287,14 +1289,17 @@ func (e *editor) InsertText(text string) {
}

// AttachFile adds a file as an attachment and inserts @filepath into the editor
func (e *editor) AttachFile(filePath string) {
func (e *editor) AttachFile(filePath string) error {
placeholder := "@" + filePath
e.addFileAttachment(placeholder)
if err := e.addFileAttachment(placeholder); err != nil {
return fmt.Errorf("failed to attach %s: %w", filePath, err)
}
currentValue := e.textarea.Value()
e.textarea.SetValue(currentValue + placeholder + " ")
e.textarea.MoveToEnd()
e.userTyped = true
e.updateAttachmentBanner()
return nil
}

// tryAddFileRef checks if word is a valid @filepath and adds it as attachment.
Expand All @@ -1315,33 +1320,41 @@ func (e *editor) tryAddFileRef(word string) {
return // not a path-like reference (e.g., @username)
}

e.addFileAttachment(word)
if err := e.addFileAttachment(word); err != nil {
slog.Debug("speculative file ref not valid", "word", word, "error", err)
}
}

// addFileAttachment adds a file reference as an attachment if valid.
// The path is resolved to an absolute path so downstream consumers
// (e.g. processFileAttachment) always receive a fully qualified path.
func (e *editor) addFileAttachment(placeholder string) {
func (e *editor) addFileAttachment(placeholder string) error {
path := strings.TrimPrefix(placeholder, "@")

// Resolve to absolute path so the attachment carries a fully qualified
// path regardless of the working directory at send time.
absPath, err := filepath.Abs(path)
if err != nil {
slog.Warn("skipping attachment: cannot resolve path", "path", path, "error", err)
return
return fmt.Errorf("cannot resolve path %s: %w", path, err)
}

// Check if it's an existing file (not directory)
info, err := os.Stat(absPath)
if err != nil || info.IsDir() {
return
info, err := validateFilePath(absPath)
if err != nil {
return fmt.Errorf("invalid file path %s: %w", absPath, err)
}
if info.IsDir() {
return fmt.Errorf("path is a directory: %s", absPath)
}

const maxFileSize = 5 * 1024 * 1024
if info.Size() >= maxFileSize {
return fmt.Errorf("file too large: %s (%s)", absPath, units.HumanSize(float64(info.Size())))
}

// Avoid duplicates
for _, att := range e.attachments {
if att.placeholder == placeholder {
return
return nil
}
}

Expand All @@ -1352,6 +1365,7 @@ func (e *editor) addFileAttachment(placeholder string) {
sizeBytes: int(info.Size()),
isTemp: false,
})
return nil
}

// collectAttachments returns structured attachments for all items referenced in
Expand Down Expand Up @@ -1451,6 +1465,28 @@ func (e *editor) SendContent() tea.Cmd {
}

func (e *editor) handlePaste(content string) bool {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM: TOCTOU Race Condition

Files are validated at line 1462 (allFilesValid() calls os.Lstat()), but between that check and the actual attachment at line 1464, another process could delete/move/modify the file.

Race window:

Time 1: allFilesValid() checks file exists ✓
Time 2: [another process deletes file]
Time 3: AttachFile() tries to use file ✗

Impact: The existing error handling (line 1465) catches this gracefully, so it won't crash. However, the redundant validation at line 1462 creates an unnecessary race window.

Recommendation: Consider removing the allFilesValid() call and rely solely on AttachFile() to validate, since it calls validateFilePath() internally anyway. This eliminates the redundant check and the TOCTOU window:

filePaths := ParsePastedFiles(content)
if len(filePaths) > 0 {
    for _, path := range filePaths {
        if err := e.AttachFile(path); err != nil {
            slog.Warn("failed to attach dropped file", "path", path, "error", err)
            return false  // Failed to parse as files, treat as text
        }
    }
    return true
}

// First, try to parse as file paths (drag-and-drop)
filePaths := ParsePastedFiles(content)
if len(filePaths) > 0 {
var attached int
for _, path := range filePaths {
if !IsSupportedFileType(path) {
break
}
if err := e.AttachFile(path); err != nil {
slog.Debug("paste path not attachable, treating as text", "path", path, "error", err)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BUG: Rollback leaves orphaned placeholder text in textarea

When handlePaste attempts to attach multiple files but one fails (e.g., unsupported type or size limit), this rollback only removes attachments from the e.attachments list. However, AttachFile() also inserts placeholder text like @/path/to/file.png into the textarea before adding to the attachments list.

The problem: removeLastNAttachments() only removes from the attachments slice, NOT from the textarea. This leaves orphaned placeholder text that will be sent as literal text in the message instead of being replaced with file content.

Example scenario:

  1. User pastes [valid.png (1MB), toolarge.png (6MB)]
  2. valid.png attaches successfully → @/path/to/valid.png inserted into textarea, added to attachments
  3. toolarge.png fails (size > 5MB)
  4. removeLastNAttachments(1) removes valid.png from attachments list
  5. Result: Textarea still contains @/path/to/valid.png but no corresponding attachment

Fix: removeLastNAttachments() should also remove the placeholder text from the textarea, or AttachFile() should not insert text until after all validations pass.

break
}
attached++
}
if attached == len(filePaths) {
return true
}
// Not all files could be attached; undo partial attachments and fall through to text paste
e.removeLastNAttachments(attached)
}

// Not file paths, handle as text paste
// Count lines (newlines + 1 for content without trailing newline)
lines := strings.Count(content, "\n") + 1
if strings.HasSuffix(content, "\n") {
Expand All @@ -1477,6 +1513,27 @@ func (e *editor) handlePaste(content string) bool {
return true
}

// removeLastNAttachments removes the last n non-temp attachments and their
// placeholder text from the textarea. Used to roll back partial file-drop
// attachments when not all files in a paste are valid.
func (e *editor) removeLastNAttachments(n int) {
if n <= 0 {
return
}
value := e.textarea.Value()
removed := 0
for i := len(e.attachments) - 1; i >= 0 && removed < n; i-- {
if !e.attachments[i].isTemp {
// Strip the placeholder text ("@/path/file.png ") that AttachFile inserted
value = strings.Replace(value, e.attachments[i].placeholder+" ", "", 1)
e.attachments = append(e.attachments[:i], e.attachments[i+1:]...)
removed++
}
}
e.textarea.SetValue(value)
e.textarea.MoveToEnd()
}

func (e *editor) updateAttachmentBanner() {
if e.banner == nil {
return
Expand Down
228 changes: 228 additions & 0 deletions pkg/tui/components/editor/paste.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package editor

import (
"os"
"path/filepath"
"slices"
"strings"
)

// validateFilePath checks that a path is safe: no path traversal, no symlinks.
func validateFilePath(path string) (os.FileInfo, error) {
if strings.Contains(path, "..") {
return nil, os.ErrPermission
}

clean := filepath.Clean(path)

info, err := os.Lstat(clean)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, os.ErrPermission
}
return info, nil
}

// Supported file extensions for drag-and-drop attachments
var supportedFileExtensions = []string{
// Images
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg",
// PDFs
".pdf",
// Text files (future)
// ".txt", ".md", ".json", ".yaml", ".yml", ".toml",
}

// ParsePastedFiles attempts to parse pasted content as file paths.
// It handles different terminal formats:
// - Unix: space-separated with backslash escaping
// - Windows Terminal: quote-wrapped paths
// - Single file: just the path
//
// Returns nil if the content doesn't look like file paths.
func ParsePastedFiles(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}

// NOTE: Rio terminal on Windows adds NULL chars for some reason.
s = strings.ReplaceAll(s, "\x00", "")

// Try simple stat first - if all lines are valid files, use them
if attemptStatAll(s) {
return strings.Split(s, "\n")
}

// Detect Windows Terminal format (quote-wrapped)
if os.Getenv("WT_SESSION") != "" {
return windowsTerminalParsePastedFiles(s)
}

// Default to Unix format (space-separated with backslash escaping)
return unixParsePastedFiles(s)
}

// attemptStatAll tries to stat each line as a file path.
// Returns true if ALL lines exist as regular files (not directories or symlinks).
func attemptStatAll(s string) bool {
lines := strings.Split(s, "\n")
if len(lines) == 0 {
return false
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH: Path Traversal & Symlink Attack Vulnerability

The attemptStatAll() function calls os.Stat() on user-provided paths without validation. This creates two critical security issues:

  1. Path Traversal: Attackers can paste paths like ../../etc/passwd or /etc/shadow to probe file existence
  2. Symlink Attack: os.Stat() follows symlinks, allowing attackers to create malicious.png -> /etc/passwd and bypass extension validation

Attack scenario:

# Attacker creates symlink
ln -s /etc/passwd /tmp/secret.png
# Then pastes: /tmp/secret.png
# Extension check passes (.png), os.Stat follows symlink, file gets attached

Fix:

info, err := os.Lstat(line)  // Use Lstat instead of Stat
if err != nil || info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
    return false
}
// Also add path validation:
cleanPath := filepath.Clean(line)
if strings.Contains(cleanPath, "..") {
    return false  // Reject path traversal attempts
}

for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
info, err := validateFilePath(line)
if err != nil || info.IsDir() {
return false
}
}
return true
}

// windowsTerminalParsePastedFiles parses Windows Terminal format.
// Windows Terminal wraps file paths in quotes: "C:\path\to\file.png"
func windowsTerminalParsePastedFiles(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}

var (
paths []string
current strings.Builder
inQuotes = false
)

for i := range len(s) {
ch := s[i]

switch {
case ch == '"':
if inQuotes {
// End of quoted section
if current.Len() > 0 {
paths = append(paths, current.String())
current.Reset()
}
inQuotes = false
} else {
// Start of quoted section
inQuotes = true
}
case inQuotes:
current.WriteByte(ch)
case ch != ' ' && ch != '\n' && ch != '\r':
// Text outside quotes is not allowed
return nil
}
}

// Add any remaining content if quotes were properly closed
if current.Len() > 0 && !inQuotes {
paths = append(paths, current.String())
}

// If quotes were not closed, return nil (malformed input)
if inQuotes {
return nil
}

return paths
}

// unixParsePastedFiles parses Unix terminal format.
// Unix terminals use space-separated paths with backslash escaping.
// Example: /path/to/file1.png /path/to/my\ file\ with\ spaces.jpg
func unixParsePastedFiles(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}

var (
paths []string
current strings.Builder
escaped = false
)

for i := range len(s) {
ch := s[i]

switch {
case escaped:
// After a backslash, add the character as-is (including space)
current.WriteByte(ch)
escaped = false
case ch == '\\':
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM: Incorrect Trailing Backslash Handling

When a path ends with a single backslash, it's treated as a literal character in the filename. On Unix, backslash is an escape character, never part of filenames. This creates invalid paths.

Problem:

  • Input: /path/to/file.png\
  • Output: /path/to/file.png\ (backslash included)
  • This file doesn't exist, causing attachment failures

Why this is wrong:
A trailing backslash in Unix shell output indicates a malformed/incomplete escape sequence. The test at line 75-79 validates this incorrect behavior.

Fix:

case ch == '\\':
    if i == len(s)-1 {
        // Trailing backslash = malformed input, strip it
        break
    } else {
        escaped = true
    }

Also update the test to expect the backslash to be stripped.

if i == len(s)-1 {
// Trailing backslash is malformed input; strip it
break
}
escaped = true
case ch == ' ' || ch == '\n' || ch == '\r':
// Space/newline separates paths (unless escaped)
if current.Len() > 0 {
paths = append(paths, current.String())
current.Reset()
}
default:
current.WriteByte(ch)
}
}

// Handle trailing backslash if present
if escaped {
current.WriteByte('\\')
}

// Add the last path if any
if current.Len() > 0 {
paths = append(paths, current.String())
}

return paths
}

// IsSupportedFileType checks if a file has a supported extension.
func IsSupportedFileType(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return slices.Contains(supportedFileExtensions, ext)
}

// GetFileType returns a human-readable file type for display.
func GetFileType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg":
return "image"
case ".pdf":
return "pdf"
case ".txt", ".md":
return "text"
case ".json", ".yaml", ".yml", ".toml":
return "config"
default:
return "file"
}
}

// GetFileIcon returns an emoji icon for a file type.
func GetFileIcon(fileType string) string {
switch fileType {
case "image":
return "🖼️"
case "pdf":
return "📄"
case "text":
return "📝"
case "config":
return "⚙️"
default:
return "📎"
}
}
Loading