// Package safeio reads and writes local files without following symlinked path components. package safeio import ( "crypto/rand" "encoding/hex" "errors" "io" "os" "path/filepath" "strings" "unicode/utf8" ) var ErrUnsafeFile = errors.New("unsafe file") // EnsurePrivateDirectory creates only the final canonical directory with the platform's // owner-only protection, or validates an existing directory has that protection. func EnsurePrivateDirectory(path string) error { if err := ValidateCanonicalPath(path); err != nil { return err } if err := requireCanonicalDirectory(filepath.Dir(path)); err != nil { return err } if err := createPrivateDirectory(path); err != nil && !errors.Is(err, os.ErrExist) { return ErrUnsafeFile } return ValidatePrivateDirectory(path) } // ValidateCanonicalPath rejects relative or lexically non-canonical paths before they are opened. func ValidateCanonicalPath(path string) error { if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.Contains(path, string(filepath.Separator)+".."+string(filepath.Separator)) { return ErrUnsafeFile } return nil } func readBoundedRegularFile(path string, file *os.File, maximum int64) ([]byte, error) { if maximum < 0 || maximum == int64(^uint64(0)>>1) { return nil, ErrUnsafeFile } info, err := file.Stat() if err != nil || !info.Mode().IsRegular() || !hasSingleLink(info) { return nil, ErrUnsafeFile } contents, err := io.ReadAll(io.LimitReader(file, maximum+1)) if err != nil || int64(len(contents)) > maximum { return nil, ErrUnsafeFile } after, err := file.Stat() if err != nil || !after.Mode().IsRegular() || !hasSingleLink(after) || !os.SameFile(info, after) { return nil, ErrUnsafeFile } current, err := os.Lstat(path) if err != nil || !current.Mode().IsRegular() || current.Mode()&os.ModeSymlink != 0 || !hasSingleLink(current) || !os.SameFile(info, current) { return nil, ErrUnsafeFile } return contents, nil } func ReadCanonicalUTF8(path string, maximum int64) (string, error) { contents, err := ReadCanonicalRegular(path, maximum) if err != nil { return "", err } if !utf8.Valid(contents) { return "", ErrUnsafeFile } return string(contents), nil } func WriteCanonicalNewFile(path string, contents []byte, mode os.FileMode) error { if err := ValidateCanonicalPath(path); err != nil { return err } parent := filepath.Dir(path) if err := requireCanonicalDirectory(parent); err != nil { return err } if info, err := os.Lstat(path); err == nil { if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode()&os.ModeType != 0 { return ErrUnsafeFile } return ErrUnsafeFile } else if !errors.Is(err, os.ErrNotExist) { return ErrUnsafeFile } file, err := createCanonicalNewPrivateFile(path, mode) if err != nil { return ErrUnsafeFile } if _, err := file.Write(contents); err != nil { _ = file.Close() _ = os.Remove(path) return ErrUnsafeFile } if err := file.Sync(); err != nil { _ = file.Close() _ = os.Remove(path) return ErrUnsafeFile } if err := file.Close(); err != nil { _ = os.Remove(path) return ErrUnsafeFile } return nil } // ReplaceCanonicalRegular durably replaces one existing private regular file without following // symlinked path components. Platform implementations keep the temporary file in the target // directory and use the platform's atomic replace primitive. func ReplaceCanonicalRegular(path string, contents []byte, mode os.FileMode) error { if err := ValidateCanonicalPath(path); err != nil || mode.Perm() != 0o600 || mode&os.ModeType != 0 { return ErrUnsafeFile } return replaceCanonicalRegular(path, contents) } // RemoveCanonicalPrivateRegular removes one existing private regular file without following a // symlinked path component. It is intended only for rolling back a file this process published. func RemoveCanonicalPrivateRegular(path string) error { if err := ValidatePrivateRegular(path); err != nil { return ErrUnsafeFile } return removeCanonicalPrivateRegular(path) } func randomTemporaryName() (string, error) { bytes := make([]byte, 16) if _, err := rand.Read(bytes); err != nil { return "", err } return ".tht-auth-" + hex.EncodeToString(bytes) + ".tmp", nil } func requireCanonicalDirectory(path string) error { if err := ValidateCanonicalPath(path); err != nil { return err } resolved, err := filepath.EvalSymlinks(path) if err != nil || resolved != path { return ErrUnsafeFile } info, err := os.Stat(path) if err != nil || !info.IsDir() { return ErrUnsafeFile } return nil }