536 lines
14 KiB
Go
536 lines
14 KiB
Go
//go:build linux
|
|
|
|
// Package securefile provides Linux-only, no-follow filesystem primitives for
|
|
// protected credential material.
|
|
package securefile
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
var (
|
|
// ErrUnsafe identifies a path, mode, or file type that cannot be trusted.
|
|
ErrUnsafe = errors.New("unsafe filesystem object")
|
|
// ErrTooLarge identifies a file that exceeds its caller-provided bound.
|
|
ErrTooLarge = errors.New("file exceeds size bound")
|
|
// ErrChanged identifies a file whose size changed while it was read.
|
|
ErrChanged = errors.New("file changed during read")
|
|
)
|
|
|
|
// Dir is an open, protected directory descriptor. Operations are rooted at the
|
|
// descriptor rather than reopening attacker-controlled path prefixes.
|
|
type Dir struct {
|
|
fd int
|
|
path string
|
|
}
|
|
|
|
// Lock serializes cooperating writers using an advisory lock on a protected
|
|
// regular file.
|
|
type Lock struct {
|
|
fd int
|
|
}
|
|
|
|
// OpenDir opens an absolute directory without following any path component and
|
|
// rejects unsafe modes on the protected final directory.
|
|
func OpenDir(path string) (*Dir, error) {
|
|
clean, err := absolutePath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fd, err := openDirectoryPath(clean)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateDirectoryFD(fd); err != nil {
|
|
_ = syscall.Close(fd)
|
|
return nil, err
|
|
}
|
|
if err := compareWithLstat(fd, clean, true); err != nil {
|
|
_ = syscall.Close(fd)
|
|
return nil, err
|
|
}
|
|
return &Dir{fd: fd, path: clean}, nil
|
|
}
|
|
|
|
// Close closes the directory descriptor.
|
|
func (d *Dir) Close() error {
|
|
if d == nil || d.fd < 0 {
|
|
return nil
|
|
}
|
|
err := syscall.Close(d.fd)
|
|
d.fd = -1
|
|
return err
|
|
}
|
|
|
|
// OpenDir opens one direct child directory without following it.
|
|
func (d *Dir) OpenDir(name string) (*Dir, error) {
|
|
if err := validName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := d.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
fd, err := syscall.Openat(d.fd, name, directoryOpenFlags, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateDirectoryFD(fd); err != nil {
|
|
_ = syscall.Close(fd)
|
|
return nil, err
|
|
}
|
|
path := filepath.Join(d.path, name)
|
|
if err := compareWithLstat(fd, path, true); err != nil {
|
|
_ = syscall.Close(fd)
|
|
return nil, err
|
|
}
|
|
return &Dir{fd: fd, path: path}, nil
|
|
}
|
|
|
|
// OpenOrCreateDir opens a protected child directory or creates it with a
|
|
// restrictive mode. A collision is reopened and fully revalidated.
|
|
func (d *Dir) OpenOrCreateDir(name string, mode os.FileMode) (*Dir, error) {
|
|
child, err := d.OpenDir(name)
|
|
if err == nil {
|
|
return child, nil
|
|
}
|
|
if !errors.Is(err, syscall.ENOENT) {
|
|
return nil, err
|
|
}
|
|
if err := validMode(mode); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := d.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := syscall.Mkdirat(d.fd, name, uint32(mode.Perm())); err != nil && !errors.Is(err, syscall.EEXIST) {
|
|
return nil, err
|
|
}
|
|
child, err = d.OpenDir(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := d.Sync(); err != nil {
|
|
_ = child.Close()
|
|
return nil, err
|
|
}
|
|
return child, nil
|
|
}
|
|
|
|
// ReadFile reads one protected regular child file, bounded by max bytes. It
|
|
// verifies file type and mode before and after the read and detects size races.
|
|
func (d *Dir) ReadFile(name string, max int) ([]byte, error) {
|
|
return d.readFile(name, max, nil)
|
|
}
|
|
|
|
// ReadSecret reads an absolute secret input whose final parent is protected and
|
|
// whose file mode is exactly 0600.
|
|
func ReadSecret(path string, max int) ([]byte, error) {
|
|
clean, err := absolutePath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parent := filepath.Dir(clean)
|
|
name := filepath.Base(clean)
|
|
if err := validName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
dir, err := OpenDir(parent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer dir.Close()
|
|
mode := uint32(0o600)
|
|
return dir.readFile(name, max, &mode)
|
|
}
|
|
|
|
// CreateSecret creates a new absolute output file with O_CREAT|O_EXCL and an
|
|
// exact mode of 0600. The caller owns and must close the returned file.
|
|
func CreateSecret(path string) (*os.File, error) {
|
|
clean, err := absolutePath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parent := filepath.Dir(clean)
|
|
name := filepath.Base(clean)
|
|
if err := validName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
dir, err := OpenDir(parent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer dir.Close()
|
|
return dir.CreateExclusive(name, 0o600)
|
|
}
|
|
|
|
// CreateExclusive creates one direct child with O_CREAT|O_EXCL|O_NOFOLLOW and
|
|
// the exact requested restrictive mode. The caller owns and must close it.
|
|
func (d *Dir) CreateExclusive(name string, mode os.FileMode) (*os.File, error) {
|
|
if err := validName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validMode(mode); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := d.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
fd, err := syscall.Openat(d.fd, name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, uint32(mode.Perm()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := syscall.Fchmod(fd, uint32(mode.Perm())); err != nil {
|
|
_ = syscall.Close(fd)
|
|
return nil, err
|
|
}
|
|
if err := validateRegularFD(fd, uint32(mode.Perm())); err != nil {
|
|
_ = syscall.Close(fd)
|
|
return nil, err
|
|
}
|
|
return os.NewFile(uintptr(fd), filepath.Join(d.path, name)), nil
|
|
}
|
|
|
|
// Exists reports whether a direct protected regular child exists. An unsafe
|
|
// collision is an error rather than an absent file.
|
|
func (d *Dir) Exists(name string) (bool, error) {
|
|
if err := validName(name); err != nil {
|
|
return false, err
|
|
}
|
|
if err := d.check(); err != nil {
|
|
return false, err
|
|
}
|
|
fd, err := syscall.Openat(d.fd, name, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
|
|
if errors.Is(err, syscall.ENOENT) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer syscall.Close(fd)
|
|
if err := validateRegularFD(fd, 0); err != nil {
|
|
return false, err
|
|
}
|
|
if err := compareWithLstat(fd, filepath.Join(d.path, name), false); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// Rename atomically renames a direct child into another protected directory.
|
|
// Callers that need no-replace semantics must serialize writers and verify the
|
|
// destination is absent before calling Rename.
|
|
func (d *Dir) Rename(oldName string, destination *Dir, newName string) error {
|
|
if err := validName(oldName); err != nil {
|
|
return err
|
|
}
|
|
if err := validName(newName); err != nil {
|
|
return err
|
|
}
|
|
if destination == nil {
|
|
return fmt.Errorf("%w: nil destination", ErrUnsafe)
|
|
}
|
|
if err := d.check(); err != nil {
|
|
return err
|
|
}
|
|
if err := destination.check(); err != nil {
|
|
return err
|
|
}
|
|
return syscall.Renameat(d.fd, oldName, destination.fd, newName)
|
|
}
|
|
|
|
// Remove removes one direct child file without following it.
|
|
func (d *Dir) Remove(name string) error {
|
|
if err := validName(name); err != nil {
|
|
return err
|
|
}
|
|
if err := d.check(); err != nil {
|
|
return err
|
|
}
|
|
return syscall.Unlinkat(d.fd, name)
|
|
}
|
|
|
|
// Sync makes prior directory entry changes durable.
|
|
func (d *Dir) Sync() error {
|
|
if err := d.check(); err != nil {
|
|
return err
|
|
}
|
|
return syscall.Fsync(d.fd)
|
|
}
|
|
|
|
// Names returns direct entry names from the protected directory descriptor.
|
|
func (d *Dir) Names() ([]string, error) {
|
|
if err := d.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
fd, err := syscall.Openat(d.fd, ".", directoryOpenFlags, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
file := os.NewFile(uintptr(fd), d.path)
|
|
entries, err := file.ReadDir(-1)
|
|
closeErr := file.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if closeErr != nil {
|
|
return nil, closeErr
|
|
}
|
|
names := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
names = append(names, entry.Name())
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// Lock opens or creates a protected 0600 lock file and acquires an exclusive
|
|
// advisory lock. Close releases the lock and descriptor.
|
|
func (d *Dir) Lock(name string) (*Lock, error) {
|
|
if err := validName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := d.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
for {
|
|
fd, err := syscall.Openat(d.fd, name, syscall.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
|
|
if errors.Is(err, syscall.ENOENT) {
|
|
fd, err = syscall.Openat(d.fd, name, syscall.O_RDWR|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)
|
|
if errors.Is(err, syscall.EEXIST) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := syscall.Fchmod(fd, 0o600); err != nil {
|
|
_ = syscall.Close(fd)
|
|
return nil, err
|
|
}
|
|
if err := d.Sync(); err != nil {
|
|
_ = syscall.Close(fd)
|
|
return nil, err
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateRegularFD(fd, 0o600); err != nil {
|
|
_ = syscall.Close(fd)
|
|
return nil, err
|
|
}
|
|
if err := compareWithLstat(fd, filepath.Join(d.path, name), false); err != nil {
|
|
_ = syscall.Close(fd)
|
|
return nil, err
|
|
}
|
|
if err := syscall.Flock(fd, syscall.LOCK_EX); err != nil {
|
|
_ = syscall.Close(fd)
|
|
return nil, err
|
|
}
|
|
return &Lock{fd: fd}, nil
|
|
}
|
|
}
|
|
|
|
// Close releases an advisory lock and closes its descriptor.
|
|
func (l *Lock) Close() error {
|
|
if l == nil || l.fd < 0 {
|
|
return nil
|
|
}
|
|
unlockErr := syscall.Flock(l.fd, syscall.LOCK_UN)
|
|
closeErr := syscall.Close(l.fd)
|
|
l.fd = -1
|
|
if unlockErr != nil {
|
|
return unlockErr
|
|
}
|
|
return closeErr
|
|
}
|
|
|
|
func (d *Dir) readFile(name string, max int, exactMode *uint32) ([]byte, error) {
|
|
if err := validName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
if max < 0 {
|
|
return nil, fmt.Errorf("%w: negative size bound", ErrUnsafe)
|
|
}
|
|
if err := d.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
fd, err := syscall.Openat(d.fd, name, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer syscall.Close(fd)
|
|
|
|
var before syscall.Stat_t
|
|
if err := syscall.Fstat(fd, &before); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateRegularStat(&before, exactMode); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := compareWithLstat(fd, filepath.Join(d.path, name), false); err != nil {
|
|
return nil, err
|
|
}
|
|
if before.Size < 0 || before.Size > int64(max) {
|
|
return nil, fmt.Errorf("%w: %d bytes", ErrTooLarge, before.Size)
|
|
}
|
|
size := int(before.Size)
|
|
data := make([]byte, size+1)
|
|
n := 0
|
|
for n < len(data) {
|
|
read, readErr := syscall.Read(fd, data[n:])
|
|
if read > 0 {
|
|
n += read
|
|
}
|
|
if errors.Is(readErr, syscall.EINTR) {
|
|
continue
|
|
}
|
|
if readErr != nil {
|
|
return nil, readErr
|
|
}
|
|
if read == 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
var after syscall.Stat_t
|
|
if err := syscall.Fstat(fd, &after); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateRegularStat(&after, exactMode); err != nil {
|
|
return nil, err
|
|
}
|
|
if before.Dev != after.Dev || before.Ino != after.Ino || before.Size != after.Size {
|
|
return nil, ErrChanged
|
|
}
|
|
if n != size {
|
|
return nil, ErrChanged
|
|
}
|
|
return data[:n], nil
|
|
}
|
|
|
|
func (d *Dir) check() error {
|
|
if d == nil || d.fd < 0 {
|
|
return fmt.Errorf("%w: closed directory", ErrUnsafe)
|
|
}
|
|
return validateDirectoryFD(d.fd)
|
|
}
|
|
|
|
func absolutePath(path string) (string, error) {
|
|
if !filepath.IsAbs(path) {
|
|
return "", fmt.Errorf("%w: path must be absolute", ErrUnsafe)
|
|
}
|
|
return filepath.Clean(path), nil
|
|
}
|
|
|
|
func openDirectoryPath(path string) (int, error) {
|
|
fd, err := syscall.Open("/", directoryOpenFlags, 0)
|
|
if err != nil {
|
|
return -1, err
|
|
}
|
|
if path == "/" {
|
|
return fd, nil
|
|
}
|
|
for _, part := range strings.Split(strings.TrimPrefix(path, "/"), "/") {
|
|
next, err := syscall.Openat(fd, part, directoryOpenFlags, 0)
|
|
_ = syscall.Close(fd)
|
|
if err != nil {
|
|
return -1, err
|
|
}
|
|
var stat syscall.Stat_t
|
|
if err := syscall.Fstat(next, &stat); err != nil {
|
|
_ = syscall.Close(next)
|
|
return -1, err
|
|
}
|
|
if stat.Mode&syscall.S_IFMT != syscall.S_IFDIR {
|
|
_ = syscall.Close(next)
|
|
return -1, fmt.Errorf("%w: non-directory path component", ErrUnsafe)
|
|
}
|
|
fd = next
|
|
}
|
|
return fd, nil
|
|
}
|
|
|
|
func compareWithLstat(fd int, path string, directory bool) error {
|
|
var opened syscall.Stat_t
|
|
if err := syscall.Fstat(fd, &opened); err != nil {
|
|
return err
|
|
}
|
|
var linked syscall.Stat_t
|
|
if err := syscall.Lstat(path, &linked); err != nil {
|
|
return err
|
|
}
|
|
if directory {
|
|
if linked.Mode&syscall.S_IFMT != syscall.S_IFDIR {
|
|
return fmt.Errorf("%w: path is not a directory", ErrUnsafe)
|
|
}
|
|
} else if linked.Mode&syscall.S_IFMT != syscall.S_IFREG {
|
|
return fmt.Errorf("%w: path is not a regular file", ErrUnsafe)
|
|
}
|
|
if opened.Dev != linked.Dev || opened.Ino != linked.Ino {
|
|
return fmt.Errorf("%w: path changed while opening", ErrUnsafe)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateDirectoryFD(fd int) error {
|
|
var stat syscall.Stat_t
|
|
if err := syscall.Fstat(fd, &stat); err != nil {
|
|
return err
|
|
}
|
|
if stat.Mode&syscall.S_IFMT != syscall.S_IFDIR {
|
|
return fmt.Errorf("%w: not a directory", ErrUnsafe)
|
|
}
|
|
if stat.Mode&0o7022 != 0 {
|
|
return fmt.Errorf("%w: unsafe directory mode %04o", ErrUnsafe, stat.Mode&0o7777)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRegularFD(fd int, exactMode uint32) error {
|
|
var stat syscall.Stat_t
|
|
if err := syscall.Fstat(fd, &stat); err != nil {
|
|
return err
|
|
}
|
|
var required *uint32
|
|
if exactMode != 0 {
|
|
required = &exactMode
|
|
}
|
|
return validateRegularStat(&stat, required)
|
|
}
|
|
|
|
func validateRegularStat(stat *syscall.Stat_t, exactMode *uint32) error {
|
|
if stat.Mode&syscall.S_IFMT != syscall.S_IFREG {
|
|
return fmt.Errorf("%w: not a regular file", ErrUnsafe)
|
|
}
|
|
if stat.Mode&0o7022 != 0 {
|
|
return fmt.Errorf("%w: unsafe file mode %04o", ErrUnsafe, stat.Mode&0o7777)
|
|
}
|
|
if exactMode != nil && stat.Mode&0o777 != *exactMode {
|
|
return fmt.Errorf("%w: file mode %04o is not %04o", ErrUnsafe, stat.Mode&0o777, *exactMode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validMode(mode os.FileMode) error {
|
|
if mode&^os.FileMode(0o777) != 0 || mode.Perm()&0o022 != 0 {
|
|
return fmt.Errorf("%w: unsafe creation mode %04o", ErrUnsafe, mode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validName(name string) error {
|
|
if name == "" || name == "." || name == ".." || strings.Contains(name, "/") || strings.ContainsRune(name, 0) {
|
|
return fmt.Errorf("%w: invalid path component", ErrUnsafe)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const directoryOpenFlags = syscall.O_RDONLY | syscall.O_DIRECTORY | syscall.O_NOFOLLOW | syscall.O_CLOEXEC
|
|
|
|
var _ io.Closer = (*Dir)(nil)
|