- AGE encryption with master password model - Core commands: init, show, insert, edit, generate, rm, mv, cp, find, grep, ls - Git integration for version control - Clipboard support (X11 and Wayland) - Secure password generation - Backup and restore functionality - Comprehensive security features - Complete documentation
164 lines
4.3 KiB
Go
164 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// checkSneakyPaths prevents path traversal attacks
|
|
// Because users will try to break things, and we'd rather not let them
|
|
func checkSneakyPaths(path string) error {
|
|
if path == "" {
|
|
return nil // Empty is fine
|
|
}
|
|
|
|
// Classic path traversal attempt - catch it early
|
|
if strings.Contains(path, "..") {
|
|
return fmt.Errorf("error: path contains '..' - path traversal not allowed")
|
|
}
|
|
|
|
// No absolute paths either - keep everything relative
|
|
if filepath.IsAbs(path) {
|
|
return fmt.Errorf("error: absolute paths not allowed")
|
|
}
|
|
|
|
// Check if path would escape store directory
|
|
storeDir := getStoreDir()
|
|
absPath, err := filepath.Abs(filepath.Join(storeDir, path))
|
|
if err != nil {
|
|
return fmt.Errorf("error: invalid path: %w", err)
|
|
}
|
|
|
|
absStoreDir, err := filepath.Abs(storeDir)
|
|
if err != nil {
|
|
return fmt.Errorf("error: invalid store directory: %w", err)
|
|
}
|
|
|
|
// Ensure the resolved path is within the store directory
|
|
relPath, err := filepath.Rel(absStoreDir, absPath)
|
|
if err != nil {
|
|
return fmt.Errorf("error: path outside store directory")
|
|
}
|
|
|
|
if strings.HasPrefix(relPath, "..") {
|
|
return fmt.Errorf("error: path escapes store directory")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// sanitizePath cleans and validates a password path
|
|
func sanitizePath(path string) (string, error) {
|
|
if err := checkSneakyPaths(path); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Clean up the path - normalize separators and such
|
|
cleaned := filepath.Clean(path)
|
|
|
|
// Trim slashes - they're not needed
|
|
cleaned = strings.Trim(cleaned, "/")
|
|
|
|
// Reasonable length limit - 4KB should be enough for anyone (famous last words)
|
|
const maxPathLength = 4096
|
|
if len(cleaned) > maxPathLength {
|
|
return "", fmt.Errorf("error: path too long (max %d characters)", maxPathLength)
|
|
}
|
|
|
|
return cleaned, nil
|
|
}
|
|
|
|
// maxFileSize limits the size of files we'll read into memory
|
|
// 10MB seems reasonable - if you need bigger passwords, maybe rethink your life choices
|
|
const maxFileSize = 10 * 1024 * 1024
|
|
|
|
// checkFileSize checks if a file is within size limits
|
|
func checkFileSize(path string) error {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if info.Size() > maxFileSize {
|
|
return fmt.Errorf("error: file too large (max %d bytes)", maxFileSize)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// maxPasswordLength limits password length to prevent DoS
|
|
// 1MB should be enough for even the most paranoid password
|
|
const maxPasswordLength = 1024 * 1024
|
|
|
|
// checkPasswordLength validates password length
|
|
func checkPasswordLength(password string) error {
|
|
if len(password) > maxPasswordLength {
|
|
return fmt.Errorf("error: password too long (max %d bytes)", maxPasswordLength)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readLimited reads up to maxBytes from a reader to prevent resource exhaustion
|
|
func readLimited(r io.Reader, maxBytes int64) ([]byte, error) {
|
|
limitedReader := io.LimitReader(r, maxBytes)
|
|
data, err := io.ReadAll(limitedReader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Check if we hit the limit (would mean file is larger than max)
|
|
if int64(len(data)) == maxBytes {
|
|
// Try to read one more byte to see if there's more
|
|
var buf [1]byte
|
|
if n, _ := r.Read(buf[:]); n > 0 {
|
|
return nil, fmt.Errorf("error: file too large (max %d bytes)", maxBytes)
|
|
}
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// createSecureTempFile creates a temporary file in a secure location
|
|
// Tries /dev/shm first (RAM-backed, faster, more secure), falls back to system temp
|
|
func createSecureTempFile(prefix, suffix string) (string, error) {
|
|
// Try /dev/shm - it's RAM-backed so it's faster and doesn't touch disk
|
|
// Not available on all systems, but worth checking
|
|
if dir := "/dev/shm"; isWritable(dir) {
|
|
tmpFile, err := os.CreateTemp(dir, prefix)
|
|
if err == nil {
|
|
tmpFile.Close()
|
|
return tmpFile.Name(), nil
|
|
}
|
|
// If it fails, just continue to fallback
|
|
}
|
|
|
|
// Fall back to whatever the OS thinks is a good temp directory
|
|
tmpFile, err := os.CreateTemp("", prefix)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tmpFile.Close()
|
|
return tmpFile.Name(), nil
|
|
}
|
|
|
|
// isWritable checks if a directory is writable
|
|
func isWritable(path string) bool {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if !info.IsDir() {
|
|
return false
|
|
}
|
|
// Try to create a test file
|
|
testFile := filepath.Join(path, ".passage-write-test")
|
|
f, err := os.Create(testFile)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
f.Close()
|
|
os.Remove(testFile)
|
|
return true
|
|
}
|