package attachment import ( "io" "os" "path/filepath" ) // ErrInvalidAttachmentID is returned when attachmentID contains unsafe characters var ErrInvalidAttachmentID = os.ErrInvalid type AttachmentManager struct { attachmentsDir string } const maxUploadSize = 50 * 1024 * 1024 // 50MB func NewAttachmentManager() *AttachmentManager { return &AttachmentManager{ attachmentsDir: filepath.Join(os.Getenv("HOME"), ".config", "pop", "attachments"), } } // isAttachmentIDSafe validates that attachmentID contains only safe characters // to prevent path traversal attacks func isAttachmentIDSafe(id string) bool { if id == "" { return false } // Only allow alphanumeric, hyphen, and underscore for _, r := range id { if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') { return false } } return true } // sanitizeAttachmentID ensures the attachmentID is safe and the resolved path // is within the attachments directory func sanitizeAttachmentID(id string) (string, error) { if !isAttachmentIDSafe(id) { return "", ErrInvalidAttachmentID } // Use filepath.Clean to resolve any .. or . components cleanID := filepath.Clean(id) if cleanID != id { return "", ErrInvalidAttachmentID } return cleanID, nil } func (m *AttachmentManager) Download(attachmentID, name, destPath string) error { // Sanitize attachmentID to prevent path traversal if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil { return err } else { attachmentID = sanitizedID } if err := os.MkdirAll(m.attachmentsDir, 0700); err != nil { return err } srcPath := filepath.Join(m.attachmentsDir, attachmentID) dest := filepath.Join(destPath, name) if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return err } data, err := os.ReadFile(srcPath) if err != nil { return err } return os.WriteFile(dest, data, 0600) } func (m *AttachmentManager) Upload(attachmentID, name string, reader io.Reader) error { // Sanitize attachmentID to prevent path traversal if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil { return err } else { attachmentID = sanitizedID } if err := os.MkdirAll(m.attachmentsDir, 0700); err != nil { return err } // Limit reader to maxUploadSize to prevent DoS limitedReader := io.LimitReader(reader, maxUploadSize) data, err := io.ReadAll(limitedReader) if err != nil { return err } return os.WriteFile(filepath.Join(m.attachmentsDir, attachmentID), data, 0600) } func (m *AttachmentManager) Get(attachmentID string) ([]byte, error) { // Sanitize attachmentID to prevent path traversal if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil { return nil, err } else { attachmentID = sanitizedID } path := filepath.Join(m.attachmentsDir, attachmentID) return os.ReadFile(path) } func (m *AttachmentManager) Delete(attachmentID string) error { // Sanitize attachmentID to prevent path traversal if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil { return err } else { attachmentID = sanitizedID } path := filepath.Join(m.attachmentsDir, attachmentID) return os.Remove(path) } func (m *AttachmentManager) List() ([]string, error) { entries, err := os.ReadDir(m.attachmentsDir) if err != nil { if os.IsNotExist(err) { return []string{}, nil } return nil, err } ids := make([]string, len(entries)) for i, entry := range entries { ids[i] = entry.Name() } return ids, nil }