// Package securefs provides descriptor-anchored file operations that never // follow path components or final-file symbolic links. package securefs import ( "crypto/rand" "encoding/hex" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "strings" "golang.org/x/sys/unix" ) type Owner struct{ UID, GID int } // ExistingTrustedRoot returns the nearest existing ancestor without resolving // symlinks. Descriptor traversal will subsequently reject a symlink ancestor. func ExistingTrustedRoot(path string) string { candidate := filepath.Dir(filepath.Clean(path)) for { if _, err := os.Lstat(candidate); err == nil || !errors.Is(err, fs.ErrNotExist) { return candidate } parent := filepath.Dir(candidate) if parent == candidate { return candidate } candidate = parent } } func OpenNoFollow(path string, flags int, mode uint32) (*os.File, error) { fd, err := unix.Open(path, flags|unix.O_CLOEXEC|unix.O_NOFOLLOW, mode) if err != nil { return nil, err } return os.NewFile(uintptr(fd), path), nil } func OpenAt(directory *os.File, name string, flags int, mode uint32) (*os.File, error) { fd, err := unix.Openat(int(directory.Fd()), name, flags|unix.O_CLOEXEC|unix.O_NOFOLLOW, mode) if err != nil { return nil, err } return os.NewFile(uintptr(fd), filepath.Join(directory.Name(), name)), nil } func CreateTemporaryAt(directory *os.File, prefix string) (*os.File, string, error) { for range 100 { bytes := make([]byte, 8) if _, err := rand.Read(bytes); err != nil { return nil, "", err } name := prefix + hex.EncodeToString(bytes) file, err := OpenAt(directory, name, unix.O_CREAT|unix.O_EXCL|unix.O_RDWR, 0o600) if errors.Is(err, fs.ErrExist) { continue } return file, name, err } return nil, "", errors.New("could not allocate a unique temporary file") } type Store struct { TrustedRoot string Owner *Owner } // OpenRegular opens a regular file through a descriptor-anchored parent path. // Parent components and the final file are never followed through symlinks. func (s Store) OpenRegular(path string, flags int, mode os.FileMode) (*os.File, error) { directory, base, err := s.openParent(path, flags&unix.O_CREAT != 0) if err != nil { return nil, err } if err := directory.Chmod(0o700); err != nil { _ = directory.Close() return nil, err } if s.Owner != nil && (s.Owner.UID != os.Geteuid() || s.Owner.GID != os.Getegid()) { if err := directory.Chown(s.Owner.UID, s.Owner.GID); err != nil { _ = directory.Close() return nil, err } } file, err := OpenAt(directory, base, flags, uint32(mode.Perm())) _ = directory.Close() if err != nil { return nil, err } if err := regular(file); err != nil { _ = file.Close() return nil, err } if err := file.Chmod(mode.Perm()); err != nil { _ = file.Close() return nil, err } if s.Owner != nil && (s.Owner.UID != os.Geteuid() || s.Owner.GID != os.Getegid()) { if err := file.Chown(s.Owner.UID, s.Owner.GID); err != nil { _ = file.Close() return nil, err } } return file, nil } type fileLock struct{ file *os.File } func (l *fileLock) Close() error { unlockErr := unix.Flock(int(l.file.Fd()), unix.LOCK_UN) closeErr := l.file.Close() if unlockErr != nil { return unlockErr } return closeErr } // LockFile acquires an exclusive advisory lock without following symlinks. func (s Store) LockFile(path string) (io.Closer, error) { file, err := s.OpenRegular(path, unix.O_CREAT|unix.O_RDWR, 0o600) if err != nil { return nil, err } if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil { _ = file.Close() return nil, err } return &fileLock{file: file}, nil } func (s Store) ReadFile(path string) ([]byte, error) { directory, base, err := s.openParent(path, false) if err != nil { return nil, err } defer directory.Close() file, err := OpenAt(directory, base, unix.O_RDONLY|unix.O_NONBLOCK, 0) if err != nil { return nil, err } defer file.Close() if err := regular(file); err != nil { return nil, err } return io.ReadAll(file) } func (s Store) FileMode(path string) (os.FileMode, error) { directory, base, err := s.openParent(path, false) if err != nil { return 0, err } defer directory.Close() file, err := OpenAt(directory, base, unix.O_RDONLY|unix.O_NONBLOCK, 0) if err != nil { return 0, err } defer file.Close() if err := regular(file); err != nil { return 0, err } info, err := file.Stat() if err != nil { return 0, err } return info.Mode().Perm(), nil } func (s Store) AtomicWrite(path string, data []byte, mode os.FileMode) error { directory, base, err := s.openParent(path, true) if err != nil { return err } defer directory.Close() if existing, openErr := OpenAt(directory, base, unix.O_RDONLY|unix.O_NONBLOCK, 0); openErr == nil { err = regular(existing) _ = existing.Close() if err != nil { return err } } else if !errors.Is(openErr, fs.ErrNotExist) { return fmt.Errorf("inspect destination without following links: %w", openErr) } temporary, temporaryBase, err := CreateTemporaryAt(directory, ".proxyctl-") if err != nil { return err } committed := false defer func() { if !committed { _ = unix.Unlinkat(int(directory.Fd()), temporaryBase, 0) } }() if s.Owner != nil && (s.Owner.UID != os.Geteuid() || s.Owner.GID != os.Getegid()) { if err := temporary.Chown(s.Owner.UID, s.Owner.GID); err != nil { _ = temporary.Close() return err } } if err := temporary.Chmod(mode); err != nil { _ = temporary.Close() return err } if _, err := temporary.Write(data); err != nil { _ = temporary.Close() return err } if err := temporary.Sync(); err != nil { _ = temporary.Close() return err } if err := temporary.Close(); err != nil { return err } if err := unix.Renameat(int(directory.Fd()), temporaryBase, int(directory.Fd()), base); err != nil { return err } committed = true return directory.Sync() } // WithLock serializes an action across processes using a descriptor-anchored, // owner-restricted advisory lock file. func (s Store) WithLock(path string, action func() error) error { directory, base, err := s.openParent(path, true) if err != nil { return err } defer directory.Close() lock, err := OpenAt(directory, base, unix.O_CREAT|unix.O_RDWR, 0o600) if err != nil { return err } defer lock.Close() if err := regular(lock); err != nil { return err } if s.Owner != nil && (s.Owner.UID != os.Geteuid() || s.Owner.GID != os.Getegid()) { if err := lock.Chown(s.Owner.UID, s.Owner.GID); err != nil { return err } } if err := lock.Chmod(0o600); err != nil { return err } if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil { return err } defer func() { _ = unix.Flock(int(lock.Fd()), unix.LOCK_UN) }() return action() } func (s Store) Remove(path string) error { directory, base, err := s.openParent(path, false) if err != nil { return err } defer directory.Close() file, err := OpenAt(directory, base, unix.O_RDONLY|unix.O_NONBLOCK, 0) if err != nil { return err } if err := regular(file); err != nil { _ = file.Close() return err } _ = file.Close() if err := unix.Unlinkat(int(directory.Fd()), base, 0); err != nil { return err } return directory.Sync() } func (s Store) openParent(path string, create bool) (*os.File, string, error) { path = filepath.Clean(path) root := filepath.Clean(s.TrustedRoot) if !filepath.IsAbs(path) || !filepath.IsAbs(root) || !within(path, root) || path == root { return nil, "", errors.New("secure file path escapes its trusted root") } parent, base := filepath.Dir(path), filepath.Base(path) current, err := OpenNoFollow(string(filepath.Separator), unix.O_RDONLY|unix.O_DIRECTORY, 0) if err != nil { return nil, "", err } currentPath := string(filepath.Separator) for _, component := range strings.Split(strings.TrimPrefix(parent, string(filepath.Separator)), string(filepath.Separator)) { if component == "" { continue } nextPath := filepath.Join(currentPath, component) next, openErr := OpenAt(current, component, unix.O_RDONLY|unix.O_DIRECTORY, 0) if errors.Is(openErr, fs.ErrNotExist) && create && nextPath != root && within(nextPath, root) { created := false if mkdirErr := unix.Mkdirat(int(current.Fd()), component, 0o700); mkdirErr != nil { if !errors.Is(mkdirErr, fs.ErrExist) { _ = current.Close() return nil, "", mkdirErr } } else { created = true } next, openErr = OpenAt(current, component, unix.O_RDONLY|unix.O_DIRECTORY, 0) if openErr == nil && created { if s.Owner != nil && (s.Owner.UID != os.Geteuid() || s.Owner.GID != os.Getegid()) { if err := next.Chown(s.Owner.UID, s.Owner.GID); err != nil { _ = next.Close() _ = current.Close() return nil, "", err } } if err := next.Chmod(0o700); err != nil { _ = next.Close() _ = current.Close() return nil, "", err } } } _ = current.Close() if openErr != nil { return nil, "", fmt.Errorf("open ancestor %q without following links: %w", nextPath, openErr) } current, currentPath = next, nextPath } return current, base, nil } func regular(file *os.File) error { info, err := file.Stat() if err != nil { return err } if !info.Mode().IsRegular() { return errors.New("destination is not a regular file") } return nil } func within(path, root string) bool { relative, err := filepath.Rel(root, path) return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) }