fix: harden Shell Target recovery and storage
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
// 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 }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user