// MsgSyncReq: copy files to the remote host // 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 . // +build !windows package sync import ( "bytes" "fmt" "io" "io/fs" "math/rand" "net/http" "os" "os/user" slashpath "path" "path/filepath" "sort" "strconv" "strings" "time" "github.com/ianbruene/go-difflib/difflib" "golang.org/x/sys/unix" "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()) // Sort for deterministic order and so parent directories are present // when files in them are created var files []*safcm.File for _, x := range s.req.Files { files = append(files, x) } sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) for _, x := range files { var changed bool err := s.syncFile(x, &changed) if err != nil { return fmt.Errorf("%q: %v", x.Path, err) } if changed { s.queueTriggers(x) } } return nil } func (s *Sync) syncFile(file *safcm.File, changed *bool) error { // The general strategy is "update by rename": If any property of a // file changes it will be written to a temporary file and then // renamed "over" the original file. This is simple and prevents race // conditions where the file is partially readable while changes to // permissions or owner/group are applied. However, this strategy does // not work for directories which must be removed first (was // directory), must remove the existing file (will be directory) or // must be directly modified (changed permissions or owner). In the // first two cases the old path is removed. In the last the directory // is modified (carefully) in place. // // 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). 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 { return err } change := safcm.FileChange{ Path: file.Path, New: safcm.FileChangeInfo{ Mode: file.Mode, User: file.User, Uid: file.Uid, Group: file.Group, Gid: file.Gid, }, } debugf := func(format string, a ...interface{}) { s.log.Debugf("files: %q (%s): %s", file.Path, file.OrigGroup, fmt.Sprintf(format, a...)) } verbosef := func(format string, a ...interface{}) { s.log.Verbosef("files: %q (%s): %s", file.Path, file.OrigGroup, fmt.Sprintf(format, a...)) } parentFd, baseName, err := OpenParentDirectoryNoSymlinks(file.Path) if err != nil { return err } defer unix.Close(parentFd) var oldStat unix.Stat_t reopen: oldFh, err := OpenAtNoFollow(parentFd, baseName) if err != nil { 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 oldStat.Mode&unix.S_IFMT != unix.S_IFLNK { debugf("type changed from symlink, retrying") goto reopen } } else if os.IsNotExist(err) { change.Created = true debugf("will create") } else { return err } } else { defer oldFh.Close() err := unix.Fstat(int(oldFh.Fd()), &oldStat) if err != nil { return err } } 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 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 // simple we don't support that and always use 0777 // for symlink permissions (the value on GNU/Linux). // // TODO: Add proper support for symlinks on BSD change.Old.Mode |= 0777 } if change.Old.Mode != file.Mode { if change.Old.Mode.Type() != file.Mode.Type() { changeType = true debugf("type differs %s -> %s", change.Old.Mode.Type(), file.Mode.Type()) } else { // Be careful with .Perm() which does not // contain the setuid/setgid/sticky bits! changePerm = true debugf("permission differs %s -> %s", change.Old.Mode, file.Mode) } } // Compare user/group 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", change.Old.Uid, change.Old.Gid, file.Uid, file.Gid) } u, g, err := resolveIdsToNames(change.Old.Uid, change.Old.Gid) // Errors are not relevant as this is only used to report the // change. If the user/group no longer exits only the ids will // be reported. if err == nil { change.Old.User = u change.Old.Group = g } // Compare file content (if possible) switch change.Old.Mode.Type() { case 0: // regular file x, err := io.ReadAll(oldFh) if err != nil { return fmt.Errorf("reading old content: %v", err) } oldData = x case fs.ModeSymlink: buf := make([]byte, unix.PathMax) n, err := unix.Readlinkat(parentFd, baseName, buf) if err != nil { return fmt.Errorf("reading old content: %v", err) } oldData = buf[:n] } if !changeType && file.Mode.Type() != fs.ModeDir { if !bytes.Equal(oldData, file.Data) { changeData = true debugf("content differs") } } } // No changes if !change.Created && !changeType && !changePerm && !changeUserOrGroup && !changeData { debugf("unchanged") return nil } *changed = true // Don't show a diff with the full content for newly created files or // on type changes. This is just noise for the user as the new file // content is obvious. But we always want to see a diff when files are // replaced because this destroys data. if !change.Created && (change.Old.Mode.Type() == 0 || change.Old.Mode.Type() == fs.ModeSymlink) { change.DataDiff, err = diffData(oldData, file.Data) if err != nil { return err } } // Add change here so it is stored even when applying it fails. This // way the user knows exactly what was attempted. s.resp.FileChanges = append(s.resp.FileChanges, change) if change.Created { verbosef("creating") } else { verbosef("updating") } if s.req.DryRun { debugf("dry-run, skipping changes") return nil } // We cannot rename over directories and vice versa if changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir()) { debugf("removing (due to type change)") // 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") 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 // a symlink at this point. There's no lchmod and fchmodat is // incomplete so open the directory. debugf("chmodding %s", file.Mode) dh, err := OpenAtNoFollow(parentFd, baseName) if err != nil { return err } defer dh.Close() err = dh.Chmod(file.Mode) if err != nil { return err } // Less restrictive access is not relevant here because there // are no files present yet. debugf("chowning %d/%d", file.Uid, file.Gid) err = dh.Chown(file.Uid, file.Gid) if err != nil { return err } return nil } // Directory: changed permission or user/group if file.Mode.IsDir() { // We don't know if the new permission or if the new // user/group is more restrictive (e.g. root:root 0750 -> // user:group 0700; applying group first gives group // unexpected access). To prevent a short window where the // access might be too lax we temporarily deny all access. if changePerm && changeUserOrGroup { // Only drop group and other permission because user // has access anyway (either before or after the // change). This also prevents temporary errors during // the error when the user tries to access this // directory (access for the group will fail though). mode := change.Old.Mode & fs.ModePerm & 0700 debugf("chmodding %#o (temporary)", mode) err := oldFh.Chmod(mode) if err != nil { return err } } if changeUserOrGroup { debugf("chowning %d/%d", file.Uid, file.Gid) err := oldFh.Chown(file.Uid, file.Gid) if err != nil { return err } } if changePerm { debugf("chmodding %s", file.Mode) err := oldFh.Chmod(file.Mode) if err != nil { return err } } return nil } 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 tmpBase := "." + baseName switch file.Mode.Type() { case 0: // regular file debugf("creating temporary file %q", slashpath.Join(dir, tmpBase+"*")) x, err := WriteTempAt(parentFd, tmpBase, file.Data, file.Uid, file.Gid, file.Mode) if err != nil { return err } tmpBase = x case fs.ModeSymlink: i := 0 retry: 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++ goto retry } return err } tmpBase = x err = unix.Fchownat(parentFd, tmpBase, file.Uid, file.Gid, unix.AT_SYMLINK_NOFOLLOW) if err != nil { unix.Unlinkat(parentFd, tmpBase, 0) return err } // Permissions are irrelevant for symlinks (on most systems) default: panic(fmt.Sprintf("invalid file type %s", file.Mode)) } debugf("renaming %q", slashpath.Join(dir, tmpBase)) err = unix.Renameat(parentFd, tmpBase, parentFd, baseName) if err != nil { unix.Unlinkat(parentFd, tmpBase, 0) return err } // To guarantee durability fsync must be called on a parent directory // after adding, renaming or removing files therein. // // Calling sync on the files itself is not enough according to POSIX; // man 2 fsync: "Calling fsync() does not necessarily ensure that the // entry in the directory containing the file has also reached disk. // For that an explicit fsync() on a file descriptor for the directory // is also needed." err = unix.Fsync(parentFd) if err != nil { return err } return nil } func (s *Sync) fileResolveIds(file *safcm.File) error { if file.User != "" && file.Uid != -1 { return fmt.Errorf("cannot set both User (%q) and Uid (%d)", file.User, file.Uid) } if file.Group != "" && file.Gid != -1 { return fmt.Errorf("cannot set both Group (%q) and Gid (%d)", file.Group, file.Gid) } if file.User == "" && file.Uid == -1 { file.User = s.defaultUser } if file.User != "" { x, err := user.Lookup(file.User) if err != nil { return err } id, err := strconv.Atoi(x.Uid) if err != nil { return err } file.Uid = id } if file.Group == "" && file.Gid == -1 { file.Group = s.defaultGroup } if file.Group != "" { x, err := user.LookupGroup(file.Group) if err != nil { return err } id, err := strconv.Atoi(x.Gid) if err != nil { return err } file.Gid = id } 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 { return "", "", err } g, err := user.LookupGroupId(strconv.Itoa(gid)) if err != nil { return "", "", err } return u.Username, g.Name, nil } func diffData(oldData []byte, newData []byte) (string, error) { oldBin := !strings.HasPrefix(http.DetectContentType(oldData), "text/") newBin := !strings.HasPrefix(http.DetectContentType(newData), "text/") if oldBin && newBin { return fmt.Sprintf("Binary files differ (%d -> %d bytes), "+ "cannot show diff", len(oldData), len(newData)), nil } if oldBin { oldData = []byte(fmt.Sprintf("\n", len(oldData))) } if newBin { newData = []byte(fmt.Sprintf("\n", len(newData))) } // TODO: difflib shows empty context lines at the end of the file // which should not be there // TODO: difflib has issues with missing newlines in either side result, err := difflib.GetUnifiedDiffString(difflib.LineDiffParams{ A: difflib.SplitLines(string(oldData)), B: difflib.SplitLines(string(newData)), Context: 3, }) if err != nil { return "", err } return result, nil } 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 WriteTempAt(dirFd int, base string, data []byte, uid, gid int, mode fs.FileMode) (string, error) { fh, tmpBase, err := createTempAt(dirFd, base) if err != nil { return "", err } _, err = fh.Write(data) if err != nil { fh.Close() unix.Unlinkat(dirFd, tmpBase, 0) return "", err } // createTempAt() creates the file with 0600 err = fh.Chown(uid, gid) if err != nil { fh.Close() unix.Unlinkat(dirFd, tmpBase, 0) return "", err } err = fh.Chmod(mode) if err != nil { fh.Close() unix.Unlinkat(dirFd, tmpBase, 0) return "", err } err = fh.Sync() if err != nil { fh.Close() unix.Unlinkat(dirFd, tmpBase, 0) return "", err } err = fh.Close() if err != nil { unix.Unlinkat(dirFd, tmpBase, 0) return "", err } 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 }