99 lines
3.5 KiB
Go
99 lines
3.5 KiB
Go
package target
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/sys/unix"
|
|
|
|
"proxyctl/internal/profile"
|
|
)
|
|
|
|
// ReadOnlyWriteAuthority observes whether the current process can write the
|
|
// configured locations. It never creates a probe file or changes permissions.
|
|
func ReadOnlyWriteAuthority(paths ...string) Check {
|
|
check := Check{Name: "write authority", OK: true}
|
|
var evidence []string
|
|
for _, path := range paths {
|
|
ok, detail := readOnlyWritablePath(path)
|
|
check.OK = check.OK && ok
|
|
evidence = append(evidence, detail)
|
|
}
|
|
check.Evidence = profile.RedactText(strings.Join(evidence, "; "))
|
|
return check
|
|
}
|
|
|
|
// readOnlyWritablePath anchors traversal at an open root descriptor and refuses
|
|
// symbolic links in every component. The authority observation therefore
|
|
// cannot be redirected to a victim between path lookup steps, and it never
|
|
// opens victim content or mutates filesystem metadata.
|
|
func readOnlyWritablePath(path string) (bool, string) {
|
|
absolute, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return false, path + " cannot be inspected: " + err.Error()
|
|
}
|
|
absolute = filepath.Clean(absolute)
|
|
components := strings.Split(strings.TrimPrefix(absolute, string(filepath.Separator)), string(filepath.Separator))
|
|
if len(components) == 1 && components[0] == "" {
|
|
components = nil
|
|
}
|
|
|
|
current, err := unix.Open(string(filepath.Separator), unix.O_PATH|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
|
if err != nil {
|
|
return false, absolute + " cannot be inspected: " + err.Error()
|
|
}
|
|
defer func() { _ = unix.Close(current) }()
|
|
currentPath := string(filepath.Separator)
|
|
|
|
for index, component := range components {
|
|
child, openErr := unix.Openat(current, component, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
|
if errors.Is(openErr, fs.ErrNotExist) {
|
|
return writableDirectory(current, currentPath, absolute)
|
|
}
|
|
if openErr != nil {
|
|
return false, filepath.Join(currentPath, component) + " cannot be inspected without following links: " + openErr.Error()
|
|
}
|
|
var stat unix.Stat_t
|
|
statErr := unix.Fstat(child, &stat)
|
|
if statErr != nil {
|
|
_ = unix.Close(child)
|
|
return false, filepath.Join(currentPath, component) + " cannot be inspected: " + statErr.Error()
|
|
}
|
|
kind := stat.Mode & unix.S_IFMT
|
|
componentPath := filepath.Join(currentPath, component)
|
|
if kind == unix.S_IFLNK {
|
|
_ = unix.Close(child)
|
|
return false, componentPath + " is a symbolic link and is unavailable for safe write observation"
|
|
}
|
|
if index == len(components)-1 {
|
|
_ = unix.Close(child)
|
|
if kind != unix.S_IFREG {
|
|
return false, componentPath + " is not a regular file"
|
|
}
|
|
readable, readErr := unix.Openat(current, component, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
|
if readErr != nil {
|
|
return false, componentPath + " cannot be read for safe atomic replacement: " + readErr.Error()
|
|
}
|
|
_ = unix.Close(readable)
|
|
return writableDirectory(current, currentPath, absolute)
|
|
}
|
|
if kind != unix.S_IFDIR {
|
|
_ = unix.Close(child)
|
|
return false, componentPath + " is not a directory"
|
|
}
|
|
_ = unix.Close(current)
|
|
current = child
|
|
currentPath = componentPath
|
|
}
|
|
return writableDirectory(current, currentPath, absolute)
|
|
}
|
|
|
|
func writableDirectory(descriptor int, directory, targetPath string) (bool, string) {
|
|
if err := unix.Faccessat(descriptor, ".", unix.W_OK|unix.X_OK, unix.AT_EACCESS); err != nil {
|
|
return false, directory + " is not writable for " + targetPath + ": " + err.Error()
|
|
}
|
|
return true, directory + " is writable for " + targetPath
|
|
}
|