]> ruderich.org/simon Gitweb - safcm/safcm.git/commitdiff
remote: guard against symlinks in earlier path components
authorSimon Ruderich <simon@ruderich.org>
Tue, 1 Jun 2021 05:59:59 +0000 (07:59 +0200)
committerSimon Ruderich <simon@ruderich.org>
Tue, 1 Jun 2021 05:59:59 +0000 (07:59 +0200)
This was only an issue when syncing files to directories where other
users have write access to the parent directory. For example when
copying files to /home/user/.ssh/authorized_keys the user could replace
.ssh with a symlink which permitted overwriting authorized_keys anywhere
on the system.

Fix possible attacks by using *at syscalls and disallowing symlinks in
all path components except for the last of synced paths. Syncing
symlinks is obviously still permitted.

cmd/safcm/fixperms.go
remote/ainsl/ainsl.go
remote/ainsl/ainsl_test.go
remote/sync/files.go
remote/sync/files_compat.go [new file with mode: 0644]
remote/sync/files_compat_openbsd.go [new file with mode: 0644]
remote/sync/files_test.go
remote/sync/files_windows.go

index 36d618f7fe1b12a0b2197f9186a0673f71c483ba..af5c65826e3310533c5e4f4748e8bd5a2ae03851 100644 (file)
@@ -101,7 +101,7 @@ func fixpermsWalkDirFunc(path string, d fs.DirEntry, err error) error {
 
 // chmodNoFollow works like os.Chmod but doesn't follow symlinks.
 func chmodNoFollow(path string, mode fs.FileMode) error {
 
 // chmodNoFollow works like os.Chmod but doesn't follow symlinks.
 func chmodNoFollow(path string, mode fs.FileMode) error {
-       x, err := sync.OpenFileNoFollow(path)
+       x, err := sync.OpenFileNoSymlinks(path)
        if err != nil {
                return err
        }
        if err != nil {
                return err
        }
index d716b2b4d5ce0868b4006a19ed47acb3f7da5ece..2a64d63439871954379b26630dabc763c1a73f44 100644 (file)
@@ -28,10 +28,11 @@ import (
        "io"
        "io/fs"
        "os"
        "io"
        "io/fs"
        "os"
-       "path/filepath"
        "strings"
        "syscall"
 
        "strings"
        "syscall"
 
+       "golang.org/x/sys/unix"
+
        "ruderich.org/simon/safcm/remote/sync"
 )
 
        "ruderich.org/simon/safcm/remote/sync"
 )
 
@@ -74,14 +75,20 @@ func handle(path string, line string, create bool) ([]string, error) {
                        line)
        }
 
                        line)
        }
 
+       parentFd, baseName, err := sync.OpenParentDirectoryNoSymlinks(path)
+       if err != nil {
+               return nil, err
+       }
+       defer unix.Close(parentFd)
+
        var changes []string
 
        var uid, gid int
        var mode fs.FileMode
        var changes []string
 
        var uid, gid int
        var mode fs.FileMode
-       data, stat, err := readFileNoFollow(path)
+       data, stat, err := readFileAtNoFollow(parentFd, baseName)
        if err != nil {
                if !os.IsNotExist(err) {
        if err != nil {
                if !os.IsNotExist(err) {
-                       return nil, err
+                       return nil, fmt.Errorf("%q: %v", path, err)
                }
                if !create {
                        return nil, fmt.Errorf(
                }
                if !create {
                        return nil, fmt.Errorf(
@@ -147,18 +154,17 @@ func handle(path string, line string, create bool) ([]string, error) {
        }
 
        // Write via temporary file and rename
        }
 
        // Write via temporary file and rename
-       dir := filepath.Dir(path)
-       base := filepath.Base(path)
-       tmpPath, err := sync.WriteTemp(dir, "."+base, data, uid, gid, mode)
+       tmpBase, err := sync.WriteTempAt(parentFd, "."+baseName,
+               data, uid, gid, mode)
        if err != nil {
                return nil, err
        }
        if err != nil {
                return nil, err
        }
-       err = os.Rename(tmpPath, path)
+       err = unix.Renameat(parentFd, tmpBase, parentFd, baseName)
        if err != nil {
        if err != nil {
-               os.Remove(tmpPath)
+               unix.Unlinkat(parentFd, tmpBase, 0)
                return nil, err
        }
                return nil, err
        }
-       err = sync.SyncPath(dir)
+       err = unix.Fsync(parentFd)
        if err != nil {
                return nil, err
        }
        if err != nil {
                return nil, err
        }
@@ -166,8 +172,8 @@ func handle(path string, line string, create bool) ([]string, error) {
        return changes, nil
 }
 
        return changes, nil
 }
 
-func readFileNoFollow(path string) ([]byte, fs.FileInfo, error) {
-       fh, err := sync.OpenFileNoFollow(path)
+func readFileAtNoFollow(dirfd int, base string) ([]byte, fs.FileInfo, error) {
+       fh, err := sync.OpenAtNoFollow(dirfd, base)
        if err != nil {
                return nil, nil, err
        }
        if err != nil {
                return nil, nil, err
        }
@@ -178,13 +184,13 @@ func readFileNoFollow(path string) ([]byte, fs.FileInfo, error) {
                return nil, nil, err
        }
        if stat.Mode().Type() != 0 /* regular file */ {
                return nil, nil, err
        }
        if stat.Mode().Type() != 0 /* regular file */ {
-               return nil, nil, fmt.Errorf("%q is not a regular file but %s",
-                       path, stat.Mode().Type())
+               return nil, nil, fmt.Errorf("not a regular file but %s",
+                       stat.Mode().Type())
        }
 
        x, err := io.ReadAll(fh)
        if err != nil {
        }
 
        x, err := io.ReadAll(fh)
        if err != nil {
-               return nil, nil, fmt.Errorf("%q: %v", path, err)
+               return nil, nil, err
        }
 
        return x, stat, nil
        }
 
        return x, stat, nil
index 3d391f0ce49ceaf5e68eb1052e0ddae1bc3671fa..ed52bb0f758f4981948e4ff9fbf5b6918f1bee8c 100644 (file)
@@ -54,10 +54,10 @@ func TestHandle(t *testing.T) {
        }
        _, uid, _, gid := ft.CurrentUserAndGroup()
 
        }
        _, uid, _, gid := ft.CurrentUserAndGroup()
 
-       symlinkExists := "open file: too many levels of symbolic links"
+       symlinkExists := "\"file\": too many levels of symbolic links"
        if runtime.GOOS == "freebsd" {
                // EMLINK instead of ELOOP
        if runtime.GOOS == "freebsd" {
                // EMLINK instead of ELOOP
-               symlinkExists = "open file: too many links"
+               symlinkExists = "\"file\": too many links"
        }
 
        tests := []struct {
        }
 
        tests := []struct {
@@ -293,7 +293,7 @@ func TestHandle(t *testing.T) {
                                },
                        },
                        nil,
                                },
                        },
                        nil,
-                       fmt.Errorf("\"file\" is not a regular file but p---------"),
+                       fmt.Errorf("\"file\": not a regular file but p---------"),
                },
        }
 
                },
        }
 
index e3fc3d981a8c17c91f78a21bade00e3f5e607619..edf81bd2cc795a2630f4b775eca25786ad838c61 100644 (file)
@@ -28,18 +28,30 @@ import (
        "net/http"
        "os"
        "os/user"
        "net/http"
        "os"
        "os/user"
+       slashpath "path"
        "path/filepath"
        "sort"
        "strconv"
        "strings"
        "path/filepath"
        "sort"
        "strconv"
        "strings"
-       "syscall"
        "time"
 
        "github.com/ianbruene/go-difflib/difflib"
        "time"
 
        "github.com/ianbruene/go-difflib/difflib"
+       "golang.org/x/sys/unix"
 
        "ruderich.org/simon/safcm"
 )
 
 
        "ruderich.org/simon/safcm"
 )
 
+// NOTE: Don't use plain os.Open, os.OpenFile, os.Remove or any other function
+// which uses absolute paths. These are vulnerable to symlink attacks. Always
+// use *at syscalls. See below for details.
+
+// openReadonlyFlags are flags for open* syscalls to safely read a file or
+// directory.
+//
+// O_NOFOLLOW prevents symlink attacks
+// O_NONBLOCK is necessary to prevent blocking on FIFOs
+const openReadonlyFlags = unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_NONBLOCK
+
 func (s *Sync) syncFiles() error {
        // To create random file names for symlinks
        rand.Seed(time.Now().UnixNano())
 func (s *Sync) syncFiles() error {
        // To create random file names for symlinks
        rand.Seed(time.Now().UnixNano())
@@ -83,7 +95,14 @@ func (s *Sync) syncFile(file *safcm.File, changed *bool) error {
        // The implementation is careful not to follow any symlinks to prevent
        // possible race conditions which can be exploited and are especially
        // dangerous when running with elevated privileges (which will most
        // The implementation is careful not to follow any symlinks to prevent
        // possible race conditions which can be exploited and are especially
        // dangerous when running with elevated privileges (which will most
-       // likely be the case).
+       // likely be the case). This includes not using absolute paths in
+       // syscalls to prevent symlink attacks when a directory is writable by
+       // other users (e.g. when syncing a file to /home/user/dir/file the
+       // user could create dir as symlink to another directory and file
+       // would be written there). To prevent this *at syscalls are used and
+       // all symlinks in the path are rejected. This still permits the user
+       // to move dir during the sync but only to places which are writable
+       // by the user which cannot be prevented.
 
        err := s.fileResolveIds(file)
        if err != nil {
 
        err := s.fileResolveIds(file)
        if err != nil {
@@ -110,26 +129,27 @@ func (s *Sync) syncFile(file *safcm.File, changed *bool) error {
                        file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
        }
 
                        file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
        }
 
-       var oldStat fs.FileInfo
+       parentFd, baseName, err := OpenParentDirectoryNoSymlinks(file.Path)
+       if err != nil {
+               return err
+       }
+       defer unix.Close(parentFd)
+
+       var oldStat unix.Stat_t
 reopen:
 reopen:
-       oldFh, err := OpenFileNoFollow(file.Path)
+       oldFh, err := OpenAtNoFollow(parentFd, baseName)
        if err != nil {
        if err != nil {
-               err := err.(*fs.PathError)
-               if err.Err == syscall.ELOOP || err.Err == syscall.EMLINK {
-                       // Check if ELOOP was caused not by O_NOFOLLOW but by
-                       // too many nested symlinks before the final path
-                       // component.
-                       x, err := os.Lstat(file.Path)
+               if err == unix.ELOOP || err == unix.EMLINK {
+                       // ELOOP from symbolic link, this is fine
+                       err := unix.Fstatat(parentFd, baseName, &oldStat,
+                               unix.AT_SYMLINK_NOFOLLOW)
                        if err != nil {
                                return err
                        }
                        if err != nil {
                                return err
                        }
-                       if x.Mode().Type() != fs.ModeSymlink {
-                               debugf("type changed from symlink to %s, retry",
-                                       x.Mode().Type())
+                       if oldStat.Mode&unix.S_IFMT != unix.S_IFLNK {
+                               debugf("type changed from symlink, retrying")
                                goto reopen
                        }
                                goto reopen
                        }
-                       // ELOOP from symbolic link, this is fine
-                       oldStat = x
                } else if os.IsNotExist(err) {
                        change.Created = true
                        debugf("will create")
                } else if os.IsNotExist(err) {
                        change.Created = true
                        debugf("will create")
@@ -139,18 +159,53 @@ reopen:
        } else {
                defer oldFh.Close()
 
        } else {
                defer oldFh.Close()
 
-               x, err := oldFh.Stat()
+               err := unix.Fstat(int(oldFh.Fd()), &oldStat)
                if err != nil {
                        return err
                }
                if err != nil {
                        return err
                }
-               oldStat = x
        }
 
        var oldData []byte
        var changeType, changePerm, changeUserOrGroup, changeData bool
        if !change.Created {
        }
 
        var oldData []byte
        var changeType, changePerm, changeUserOrGroup, changeData bool
        if !change.Created {
+               // Manually convert to FileMode; from src/os/stat_linux.go in
+               // Go's sources (stat_*.go for other UNIX systems are
+               // identical, except for stat_darwin.go which has an extra
+               // S_IFWHT)
+               mode := fs.FileMode(oldStat.Mode & 0777)
+               switch oldStat.Mode & unix.S_IFMT {
+               case unix.S_IFBLK:
+                       mode |= fs.ModeDevice
+               case unix.S_IFCHR:
+                       mode |= fs.ModeDevice | fs.ModeCharDevice
+               case unix.S_IFDIR:
+                       mode |= fs.ModeDir
+               case unix.S_IFIFO:
+                       mode |= fs.ModeNamedPipe
+               case unix.S_IFLNK:
+                       mode |= fs.ModeSymlink
+               case unix.S_IFREG:
+                       // nothing to do
+               case unix.S_IFSOCK:
+                       mode |= fs.ModeSocket
+               // Guard against unknown file types (e.g. on darwin); not in
+               // stat_*.go
+               default:
+                       return fmt.Errorf("unexpected file mode %v",
+                               oldStat.Mode&unix.S_IFMT)
+               }
+               if oldStat.Mode&unix.S_ISGID != 0 {
+                       mode |= fs.ModeSetgid
+               }
+               if oldStat.Mode&unix.S_ISUID != 0 {
+                       mode |= fs.ModeSetuid
+               }
+               if oldStat.Mode&unix.S_ISVTX != 0 {
+                       mode |= fs.ModeSticky
+               }
+
                // Compare permissions
                // Compare permissions
-               change.Old.Mode = oldStat.Mode()
+               change.Old.Mode = mode
                if change.Old.Mode.Type() == fs.ModeSymlink {
                        // Some BSD systems permit changing permissions of
                        // symlinks but ignore them on traversal. To keep it
                if change.Old.Mode.Type() == fs.ModeSymlink {
                        // Some BSD systems permit changing permissions of
                        // symlinks but ignore them on traversal. To keep it
@@ -176,12 +231,8 @@ reopen:
                }
 
                // Compare user/group
                }
 
                // Compare user/group
-               x, ok := oldStat.Sys().(*syscall.Stat_t)
-               if !ok {
-                       return fmt.Errorf("unsupported Stat().Sys()")
-               }
-               change.Old.Uid = int(x.Uid)
-               change.Old.Gid = int(x.Gid)
+               change.Old.Uid = int(oldStat.Uid)
+               change.Old.Gid = int(oldStat.Gid)
                if change.Old.Uid != file.Uid || change.Old.Gid != file.Gid {
                        changeUserOrGroup = true
                        debugf("uid/gid differs %d/%d -> %d/%d",
                if change.Old.Uid != file.Uid || change.Old.Gid != file.Gid {
                        changeUserOrGroup = true
                        debugf("uid/gid differs %d/%d -> %d/%d",
@@ -207,12 +258,13 @@ reopen:
                        }
                        oldData = x
                case fs.ModeSymlink:
                        }
                        oldData = x
                case fs.ModeSymlink:
-                       x, err := os.Readlink(file.Path)
+                       buf := make([]byte, unix.PathMax)
+                       n, err := unix.Readlinkat(parentFd, baseName, buf)
                        if err != nil {
                                return fmt.Errorf("reading old content: %v",
                                        err)
                        }
                        if err != nil {
                                return fmt.Errorf("reading old content: %v",
                                        err)
                        }
-                       oldData = []byte(x)
+                       oldData = buf[:n]
                }
                if !changeType && file.Mode.Type() != fs.ModeDir {
                        if !bytes.Equal(oldData, file.Data) {
                }
                if !changeType && file.Mode.Type() != fs.ModeDir {
                        if !bytes.Equal(oldData, file.Data) {
@@ -221,7 +273,6 @@ reopen:
                        }
                }
        }
                        }
                }
        }
-       oldStat = nil // prevent accidental use
 
        // No changes
        if !change.Created && !changeType &&
 
        // No changes
        if !change.Created && !changeType &&
@@ -263,25 +314,43 @@ reopen:
        // We cannot rename over directories and vice versa
        if changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir()) {
                debugf("removing (due to type change)")
        // We cannot rename over directories and vice versa
        if changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir()) {
                debugf("removing (due to type change)")
-               err := os.RemoveAll(file.Path)
-               if err != nil {
-                       return err
+               // In the past os.RemoveAll() was used here. However, this is
+               // difficult to implement manually with *at syscalls. To keep
+               // it simple only permit removing files and empty directories
+               // here. This also has the bonus of preventing data loss when
+               // (accidentally) replacing a directory tree with a file.
+               const msg = "will not replace non-empty directory, " +
+                       "please remove manually"
+               err := unix.Unlinkat(parentFd, baseName, 0)
+               if err != nil && !os.IsNotExist(err) {
+                       err2 := unix.Unlinkat(parentFd, baseName,
+                               AT_REMOVEDIR)
+                       if err2 != nil && !os.IsNotExist(err2) {
+                               // See src/os/file_unix.go in Go's sources
+                               if err2 == unix.ENOTDIR {
+                                       return err
+                               } else if err2 == unix.ENOTEMPTY {
+                                       return fmt.Errorf(msg)
+                               } else {
+                                       return err2
+                               }
+                       }
                }
        }
 
        // Directory: create new directory, also type change to directory
        if file.Mode.IsDir() && (change.Created || changeType) {
                debugf("creating directory")
                }
        }
 
        // Directory: create new directory, also type change to directory
        if file.Mode.IsDir() && (change.Created || changeType) {
                debugf("creating directory")
-               err := os.Mkdir(file.Path, 0700)
+               err := unix.Mkdirat(parentFd, baseName, 0700)
                if err != nil {
                        return err
                }
                // We must be careful not to chmod arbitrary files. If the
                // target directory is writable then it might have changed to
                if err != nil {
                        return err
                }
                // We must be careful not to chmod arbitrary files. If the
                // target directory is writable then it might have changed to
-               // a symlink at this point. There's no lchmod so open the
-               // directory.
+               // a symlink at this point. There's no lchmod and fchmodat is
+               // incomplete so open the directory.
                debugf("chmodding %s", file.Mode)
                debugf("chmodding %s", file.Mode)
-               dh, err := OpenFileNoFollow(file.Path)
+               dh, err := OpenAtNoFollow(parentFd, baseName)
                if err != nil {
                        return err
                }
                if err != nil {
                        return err
                }
@@ -337,31 +406,29 @@ reopen:
                return nil
        }
 
                return nil
        }
 
-       dir := filepath.Dir(file.Path)
+       dir := slashpath.Dir(file.Path) // only used in debug messages
        // Create hidden file which should be ignored by most other tools and
        // thus not affect anything during creation
        // Create hidden file which should be ignored by most other tools and
        // thus not affect anything during creation
-       base := "." + filepath.Base(file.Path)
+       tmpBase := "." + baseName
 
 
-       var tmpPath string
        switch file.Mode.Type() {
        case 0: // regular file
                debugf("creating temporary file %q",
        switch file.Mode.Type() {
        case 0: // regular file
                debugf("creating temporary file %q",
-                       filepath.Join(dir, base+"*"))
-               tmpPath, err = WriteTemp(dir, base, file.Data,
+                       slashpath.Join(dir, tmpBase+"*"))
+               x, err := WriteTempAt(parentFd, tmpBase, file.Data,
                        file.Uid, file.Gid, file.Mode)
                if err != nil {
                        return err
                }
                        file.Uid, file.Gid, file.Mode)
                if err != nil {
                        return err
                }
+               tmpBase = x
 
        case fs.ModeSymlink:
                i := 0
        retry:
 
        case fs.ModeSymlink:
                i := 0
        retry:
-               // Similar to os.CreateTemp() but for symlinks which we cannot
-               // open as file
-               tmpPath = filepath.Join(dir,
-                       base+strconv.Itoa(rand.Int()))
-               debugf("creating temporary symlink %q", tmpPath)
-               err := os.Symlink(string(file.Data), tmpPath)
+               x := tmpBase + strconv.Itoa(rand.Int())
+               debugf("creating temporary symlink %q",
+                       slashpath.Join(dir, x))
+               err := unix.Symlinkat(string(file.Data), parentFd, x)
                if err != nil {
                        if os.IsExist(err) && i < 10000 {
                                i++
                if err != nil {
                        if os.IsExist(err) && i < 10000 {
                                i++
@@ -369,9 +436,12 @@ reopen:
                        }
                        return err
                }
                        }
                        return err
                }
-               err = os.Lchown(tmpPath, file.Uid, file.Gid)
+               tmpBase = x
+
+               err = unix.Fchownat(parentFd, tmpBase, file.Uid, file.Gid,
+                       unix.AT_SYMLINK_NOFOLLOW)
                if err != nil {
                if err != nil {
-                       os.Remove(tmpPath)
+                       unix.Unlinkat(parentFd, tmpBase, 0)
                        return err
                }
                // Permissions are irrelevant for symlinks (on most systems)
                        return err
                }
                // Permissions are irrelevant for symlinks (on most systems)
@@ -380,13 +450,13 @@ reopen:
                panic(fmt.Sprintf("invalid file type %s", file.Mode))
        }
 
                panic(fmt.Sprintf("invalid file type %s", file.Mode))
        }
 
-       debugf("renaming %q", tmpPath)
-       err = os.Rename(tmpPath, file.Path)
+       debugf("renaming %q", slashpath.Join(dir, tmpBase))
+       err = unix.Renameat(parentFd, tmpBase, parentFd, baseName)
        if err != nil {
        if err != nil {
-               os.Remove(tmpPath)
+               unix.Unlinkat(parentFd, tmpBase, 0)
                return err
        }
                return err
        }
-       err = SyncPath(dir)
+       err = unix.Fsync(parentFd)
        if err != nil {
                return err
        }
        if err != nil {
                return err
        }
@@ -437,6 +507,72 @@ func (s *Sync) fileResolveIds(file *safcm.File) error {
        return nil
 }
 
        return nil
 }
 
+// OpenParentDirectoryNoSymlinks opens the dirname of path without following
+// any symlinks and returns a file descriptor to it and the basename of path.
+// To prevent symlink attacks in earlier path components when these are
+// writable by other users it starts at the root (or current directory for
+// relative paths) and uses openat (with O_NOFOLLOW) for each path component.
+// If a symlink is encountered it returns an error. However, it's impossible
+// to guarantee that the returned descriptor refers to the same location as
+// given in path because users with write access can rename path components.
+// But this is not required to prevent the mentioned attacks.
+func OpenParentDirectoryNoSymlinks(path string) (int, string, error) {
+       // Slash separated paths are used for the configuration
+       parts := strings.Split(path, "/")
+
+       var dir string
+       if path == "/" {
+               // Root: use root itself as base name because root is the
+               // parent of itself
+               dir = "/"
+               parts = []string{"/"}
+       } else if parts[0] == "" {
+               // Absolute path
+               dir = "/"
+               parts = parts[1:]
+       } else if path == "." {
+               // Current directory: open parent directory and use current
+               // directory name as base name
+               wd, err := os.Getwd()
+               if err != nil {
+                       return -1, "", fmt.Errorf(
+                               "failed to get working directory: %w", err)
+               }
+               dir = ".."
+               parts = []string{filepath.Base(wd)}
+       } else if parts[0] != "." {
+               // Relative path: start at the current directory
+               dir = "."
+       }
+
+       dirFd, err := unix.Openat(unix.AT_FDCWD, dir, openReadonlyFlags, 0)
+       if err != nil {
+               return -1, "", err
+       }
+       // Walk path one directory at a time to ensure there are no symlinks
+       // in the path. This prevents users with write access to change the
+       // path to point to arbitrary locations. O_NOFOLLOW when opening the
+       // path is not enough as only the last path component is checked.
+       for i, name := range parts[:len(parts)-1] {
+               fd, err := unix.Openat(dirFd, name, openReadonlyFlags, 0)
+               if err != nil {
+                       unix.Close(dirFd)
+                       if err == unix.ELOOP || err == unix.EMLINK {
+                               x := filepath.Join(append([]string{dir},
+                                       parts[:i+1]...)...)
+                               return -1, "", fmt.Errorf(
+                                       "symlink not permitted in path: %q",
+                                       x)
+                       }
+                       return -1, "", err
+               }
+               unix.Close(dirFd)
+               dirFd = fd
+       }
+
+       return dirFd, parts[len(parts)-1], nil
+}
+
 func resolveIdsToNames(uid, gid int) (string, string, error) {
        u, err := user.LookupId(strconv.Itoa(uid))
        if err != nil {
 func resolveIdsToNames(uid, gid int) (string, string, error) {
        u, err := user.LookupId(strconv.Itoa(uid))
        if err != nil {
@@ -479,54 +615,85 @@ func diffData(oldData []byte, newData []byte) (string, error) {
        return result, nil
 }
 
        return result, nil
 }
 
-func OpenFileNoFollow(path string) (*os.File, error) {
-       return os.OpenFile(path,
-               // O_NOFOLLOW prevents symlink attacks
-               // O_NONBLOCK is necessary to prevent blocking on FIFOs
-               os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
+func OpenFileNoSymlinks(path string) (*os.File, error) {
+       parentFd, baseName, err := OpenParentDirectoryNoSymlinks(path)
+       if err != nil {
+               return nil, err
+       }
+       defer unix.Close(parentFd)
+       return OpenAtNoFollow(parentFd, baseName)
+}
+
+func OpenAtNoFollow(dirFd int, base string) (*os.File, error) {
+       fd, err := unix.Openat(dirFd, base, openReadonlyFlags, 0)
+       if err != nil {
+               return nil, err
+       }
+       return os.NewFile(uintptr(fd), ""), nil
 }
 
 }
 
-func WriteTemp(dir, base string, data []byte, uid, gid int, mode fs.FileMode) (
-       string, error) {
+func WriteTempAt(dirFd int, base string, data []byte, uid, gid int,
+       mode fs.FileMode) (string, error) {
 
 
-       fh, err := os.CreateTemp(dir, base)
+       fh, tmpBase, err := createTempAt(dirFd, base)
        if err != nil {
                return "", err
        }
        if err != nil {
                return "", err
        }
-       tmpPath := fh.Name()
 
        _, err = fh.Write(data)
        if err != nil {
                fh.Close()
 
        _, err = fh.Write(data)
        if err != nil {
                fh.Close()
-               os.Remove(tmpPath)
+               unix.Unlinkat(dirFd, tmpBase, 0)
                return "", err
        }
                return "", err
        }
-       // CreateTemp() creates the file with 0600
+       // createTempAt() creates the file with 0600
        err = fh.Chown(uid, gid)
        if err != nil {
                fh.Close()
        err = fh.Chown(uid, gid)
        if err != nil {
                fh.Close()
-               os.Remove(tmpPath)
+               unix.Unlinkat(dirFd, tmpBase, 0)
                return "", err
        }
        err = fh.Chmod(mode)
        if err != nil {
                fh.Close()
                return "", err
        }
        err = fh.Chmod(mode)
        if err != nil {
                fh.Close()
-               os.Remove(tmpPath)
+               unix.Unlinkat(dirFd, tmpBase, 0)
                return "", err
        }
        err = fh.Sync()
        if err != nil {
                fh.Close()
                return "", err
        }
        err = fh.Sync()
        if err != nil {
                fh.Close()
-               os.Remove(tmpPath)
+               unix.Unlinkat(dirFd, tmpBase, 0)
                return "", err
        }
        err = fh.Close()
        if err != nil {
                return "", err
        }
        err = fh.Close()
        if err != nil {
-               os.Remove(tmpPath)
+               unix.Unlinkat(dirFd, tmpBase, 0)
                return "", err
        }
 
                return "", err
        }
 
-       return tmpPath, nil
+       return tmpBase, nil
+}
+
+// createTempAt works similar to os.CreateTemp but uses unix.Openat to create
+// the temporary file.
+func createTempAt(dirFd int, base string) (*os.File, string, error) {
+       var tmpBase string
+
+       i := 0
+retry:
+       tmpBase = base + strconv.Itoa(rand.Int())
+
+       fd, err := unix.Openat(dirFd, tmpBase,
+               unix.O_RDWR|unix.O_CREAT|unix.O_EXCL, 0600)
+       if err != nil {
+               if os.IsExist(err) && i < 10000 {
+                       i++
+                       goto retry
+               }
+               return nil, "", err
+       }
+
+       return os.NewFile(uintptr(fd), ""), tmpBase, nil
 }
 
 // SyncPath syncs path, which should be a directory. To guarantee durability
 }
 
 // SyncPath syncs path, which should be a directory. To guarantee durability
diff --git a/remote/sync/files_compat.go b/remote/sync/files_compat.go
new file mode 100644 (file)
index 0000000..b293148
--- /dev/null
@@ -0,0 +1,26 @@
+// MsgSyncReq: copy files to the remote host; compatibility
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+// +build !openbsd,!windows
+
+package sync
+
+import (
+       "golang.org/x/sys/unix"
+)
+
+const AT_REMOVEDIR = unix.AT_REMOVEDIR
diff --git a/remote/sync/files_compat_openbsd.go b/remote/sync/files_compat_openbsd.go
new file mode 100644 (file)
index 0000000..f684883
--- /dev/null
@@ -0,0 +1,26 @@
+// MsgSyncReq: copy files to the remote host; OpenBSD compatibility
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+// +build openbsd
+
+package sync
+
+// Currently not defined in golang.org/x/sys/unix, see also
+// https://github.com/golang/go/issues/46342
+//
+// From /usr/include/fcntl.h
+const AT_REMOVEDIR = 0x08
index 39714594382445369936666719d625d4351cd820..3d19e7f9a3b950e180326f7930efd7d019d57369 100644 (file)
@@ -963,7 +963,7 @@ func TestSyncFile(t *testing.T) {
                                `4: files: "file" (group): will create`,
                                `3: files: "file" (group): creating`,
                                `4: files: "file" (group): creating temporary file ".file*"`,
                                `4: files: "file" (group): will create`,
                                `3: files: "file" (group): creating`,
                                `4: files: "file" (group): creating temporary file ".file*"`,
-                               `4: files: "file" (group): renaming "./.fileRND"`,
+                               `4: files: "file" (group): renaming ".fileRND"`,
                        },
                        nil,
                },
                        },
                        nil,
                },
@@ -1116,7 +1116,7 @@ func TestSyncFile(t *testing.T) {
                                `4: files: "file" (group): permission differs -rwxr-xr-x -> urwxr-xr-x`,
                                `3: files: "file" (group): updating`,
                                `4: files: "file" (group): creating temporary file ".file*"`,
                                `4: files: "file" (group): permission differs -rwxr-xr-x -> urwxr-xr-x`,
                                `3: files: "file" (group): updating`,
                                `4: files: "file" (group): creating temporary file ".file*"`,
-                               `4: files: "file" (group): renaming "./.fileRND"`,
+                               `4: files: "file" (group): renaming ".fileRND"`,
                        },
                        nil,
                },
                        },
                        nil,
                },
@@ -1174,7 +1174,7 @@ func TestSyncFile(t *testing.T) {
                                `4: files: "file" (group): content differs`,
                                `3: files: "file" (group): updating`,
                                `4: files: "file" (group): creating temporary file ".file*"`,
                                `4: files: "file" (group): content differs`,
                                `3: files: "file" (group): updating`,
                                `4: files: "file" (group): creating temporary file ".file*"`,
-                               `4: files: "file" (group): renaming "./.fileRND"`,
+                               `4: files: "file" (group): renaming ".fileRND"`,
                        },
                        nil,
                },
                        },
                        nil,
                },
@@ -1734,7 +1734,7 @@ func TestSyncFile(t *testing.T) {
                                `4: files: "path" (group): type differs L--------- -> ----------`,
                                `3: files: "path" (group): updating`,
                                `4: files: "path" (group): creating temporary file ".path*"`,
                                `4: files: "path" (group): type differs L--------- -> ----------`,
                                `3: files: "path" (group): updating`,
                                `4: files: "path" (group): creating temporary file ".path*"`,
-                               `4: files: "path" (group): renaming "./.pathRND"`,
+                               `4: files: "path" (group): renaming ".pathRND"`,
                        },
                        nil,
                },
                        },
                        nil,
                },
@@ -1845,10 +1845,66 @@ func TestSyncFile(t *testing.T) {
                                `3: files: "path" (group): updating`,
                                `4: files: "path" (group): removing (due to type change)`,
                                `4: files: "path" (group): creating temporary file ".path*"`,
                                `3: files: "path" (group): updating`,
                                `4: files: "path" (group): removing (due to type change)`,
                                `4: files: "path" (group): creating temporary file ".path*"`,
-                               `4: files: "path" (group): renaming "./.pathRND"`,
+                               `4: files: "path" (group): renaming ".pathRND"`,
                        },
                        nil,
                },
                        },
                        nil,
                },
+               {
+                       "change: directory to file (non-empty)",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "path",
+                               Mode:      0666,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                               Data:      []byte("content\n"),
+                       },
+                       func() {
+                               ft.CreateDirectory("path", 0777)
+                               ft.CreateFile("path/file", "content\n", 0644)
+                       },
+                       true,
+                       []ft.File{
+                               root,
+                               {
+                                       Path: "path",
+                                       Mode: fs.ModeDir | 0777,
+                               },
+                               {
+                                       Path: "path/file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "path",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0666,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: files: "path" (group): type differs d--------- -> ----------`,
+                               `3: files: "path" (group): updating`,
+                               `4: files: "path" (group): removing (due to type change)`,
+                       },
+                       fmt.Errorf("will not replace non-empty directory, please remove manually"),
+               },
 
                {
                        "change: directory to symlink",
 
                {
                        "change: directory to symlink",
@@ -1952,7 +2008,7 @@ func TestSyncFile(t *testing.T) {
                                `4: files: "path" (group): type differs p--------- -> ----------`,
                                `3: files: "path" (group): updating`,
                                `4: files: "path" (group): creating temporary file ".path*"`,
                                `4: files: "path" (group): type differs p--------- -> ----------`,
                                `3: files: "path" (group): updating`,
                                `4: files: "path" (group): creating temporary file ".path*"`,
-                               `4: files: "path" (group): renaming "./.pathRND"`,
+                               `4: files: "path" (group): renaming ".pathRND"`,
                        },
                        nil,
                },
                        },
                        nil,
                },
@@ -2116,6 +2172,41 @@ func TestSyncFile(t *testing.T) {
                        nil,
                },
 
                        nil,
                },
 
+               // Symlink "attacks"
+
+               {
+                       "symlink in earlier path component",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "dir/file",
+                               Mode:      0644,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                               Data:      []byte("content"),
+                       },
+                       func() {
+                               ft.CreateDirectory("tmp", 0755)
+                               ft.CreateSymlink("dir", "tmp")
+                       },
+                       false,
+                       []ft.File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Data: []byte("tmp"),
+                               },
+                               {
+                                       Path: "tmp",
+                                       Mode: fs.ModeDir | 0755,
+                               },
+                       },
+                       safcm.MsgSyncResp{},
+                       nil,
+                       fmt.Errorf("symlink not permitted in path: \"dir\""),
+               },
+
                // Diffs
 
                {
                // Diffs
 
                {
index 11252efff52ba0dc02cd402d4c00d7c732adee76..b440ed6f34a0adea1c865820eaee16d573a0c2b9 100644 (file)
@@ -29,6 +29,6 @@ func (s *Sync) syncFiles() error {
        return fmt.Errorf("not implemented on Windows")
 }
 
        return fmt.Errorf("not implemented on Windows")
 }
 
-func OpenFileNoFollow(path string) (*os.File, error) {
+func OpenFileNoSymlinks(path string) (*os.File, error) {
        return nil, fmt.Errorf("not implemented on Windows")
 }
        return nil, fmt.Errorf("not implemented on Windows")
 }