Judge an opened file's kind and links below a home, and more ways to root
A hard link swapped in for ~/.claude would have had root chown another account's file; fstat on the descriptor now refuses a second link, a fifo or an unexpected kind before anything is changed (hq ADR 0266, the re-review). The judge also finds polkit rules for every account, a runtime's API on TCP, setgid-to-root programs whoever owns them, setuid programs on every suid filesystem, and unprotected links; the rest is listed as not judged.
This commit is contained in:
@@ -996,7 +996,7 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) {
|
||||
// Set explicitly even when it existed: MkdirAll applies the mode only on creation, and a
|
||||
// permission set at creation is not a permission maintained — a lesson this repository
|
||||
// already paid for once, with world-readable environment files.
|
||||
if err := chmodPath(r.Path, mode); err != nil {
|
||||
if err := chmodDirPath(r.Path, mode); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,28 @@ func refuseLinksUnderHome(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HardLinkUnderHomeError is a regular file below a home with more than one link: the account that owns the
|
||||
// home may have linked a file it does not own there, and root acting on it by name would act on that file
|
||||
// (novox/hq ADR 0266). Refused, whatever the resource.
|
||||
type HardLinkUnderHomeError struct {
|
||||
Path string
|
||||
Links uint64
|
||||
}
|
||||
|
||||
func (e *HardLinkUnderHomeError) Error() string {
|
||||
return fmt.Sprintf("%s has %d links: below a home a file with more than one is not owned, chmodded or read "+
|
||||
"as root, since one of its names may be a file the home's account does not own", e.Path, e.Links)
|
||||
}
|
||||
|
||||
// chmodDirPath is chmodPath for a directory resource: below a home, what is there must be a directory.
|
||||
func chmodDirPath(path string, mode os.FileMode) error {
|
||||
home := homeAbove(path)
|
||||
if home == "" {
|
||||
return os.Chmod(path, mode)
|
||||
}
|
||||
return chmodUnderAs(home, path, mode, kindDir)
|
||||
}
|
||||
|
||||
// statPath is os.Stat, except below a home, where it never follows a link.
|
||||
func statPath(path string) (os.FileInfo, error) {
|
||||
if homeAbove(path) != "" {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
@@ -203,3 +204,52 @@ func TestHomesAreAPersonsOrAnAgentsNotAServicesOrRoots(t *testing.T) {
|
||||
t.Fatal("strictly below a person's or an agent's home, and nothing else")
|
||||
}
|
||||
}
|
||||
|
||||
// A hard link below a home to a file the home's account does not own is never owned, chmodded or read by
|
||||
// name: fstat on the opened descriptor counts its links first (the re-review of 2026-10-08).
|
||||
func TestAHardLinkedFileBelowAHomeIsRefused(t *testing.T) {
|
||||
home, outside := aLinkedHome(t)
|
||||
shadow := filepath.Join(outside, "shadow")
|
||||
linked := filepath.Join(home, ".claude")
|
||||
if err := os.Link(shadow, linked); err != nil {
|
||||
t.Skipf("no hard link here: %v", err)
|
||||
}
|
||||
var hard *HardLinkUnderHomeError
|
||||
if err := chmodPath(linked, 0o777); !errors.As(err, &hard) {
|
||||
t.Fatalf("chmod of a hard-linked file below a home is refused, got %v", err)
|
||||
}
|
||||
if err := chownPath(linked, os.Getuid(), os.Getgid()); !errors.As(err, &hard) {
|
||||
t.Fatalf("chown of it is refused, got %v", err)
|
||||
}
|
||||
if _, err := readPath(linked); !errors.As(err, &hard) {
|
||||
t.Fatalf("reading it is refused, got %v", err)
|
||||
}
|
||||
if m := modeOfPath(t, shadow); m != 0o600 {
|
||||
t.Fatalf("the other file's mode changed: %o", m)
|
||||
}
|
||||
// As the directory resource sees it: a file where ~/.claude should be is left alone.
|
||||
if err := chmodDirPath(linked, 0o700); err == nil || !strings.Contains(err.Error(), "where a directory was expected") {
|
||||
t.Fatalf("a file where a directory was expected is refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADirectoryWhereAFileWasExpectedAndAFifoAreRefused(t *testing.T) {
|
||||
home, _ := aLinkedHome(t)
|
||||
dir := filepath.Join(home, "d")
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readPath(dir); err == nil || !strings.Contains(err.Error(), "where a file was expected") {
|
||||
t.Fatalf("a directory where a file was expected is refused, got %v", err)
|
||||
}
|
||||
fifo := filepath.Join(home, "f")
|
||||
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Skipf("no fifo here: %v", err)
|
||||
}
|
||||
if err := chmodPath(fifo, 0o644); err == nil || !strings.Contains(err.Error(), "neither a file nor a directory") {
|
||||
t.Fatalf("a fifo is refused, got %v", err)
|
||||
}
|
||||
if err := chmodDirPath(dir, 0o700); err != nil {
|
||||
t.Fatalf("a directory as a directory passes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,22 +54,85 @@ func linkOr(at, home, path, op string, err error) error {
|
||||
return &os.PathError{Op: op, Path: at, Err: err}
|
||||
}
|
||||
|
||||
// What a caller expects to find at a path below a home: either, a directory, or a regular file.
|
||||
const (
|
||||
kindAny = iota
|
||||
kindDir
|
||||
kindFile
|
||||
)
|
||||
|
||||
// openUnder opens the file or directory at path below home, following no link, for its metadata.
|
||||
func openUnder(home, path string) (int, error) {
|
||||
func openUnder(home, path string) (int, error) { return openUnderAs(home, path, kindAny) }
|
||||
|
||||
// openUnderAs opens path below home as what the caller expects, and refuses what root must not act on
|
||||
// (novox/hq ADR 0266): a directory is opened with O_DIRECTORY|O_NOFOLLOW; whatever was opened is judged by
|
||||
// fstat on the descriptor itself, before any fchown, fchmod or read — so a name swapped after a check is
|
||||
// judged as what it now is. Refused: anything but a directory or a regular file (a device, a fifo, a
|
||||
// socket), a kind other than the one expected, and a regular file with more than one link. The account that
|
||||
// owns the home can hard-link a file it does not own (root's, where fs.protected_hardlinks is off) into its
|
||||
// home; owned or chmodded by name, root would hand that file over.
|
||||
func openUnderAs(home, path string, want int) (int, error) {
|
||||
dir, err := openDirUnder(home, filepath.Dir(path))
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
defer unix.Close(dir)
|
||||
fd, err := unix.Openat(dir, filepath.Base(path), unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0)
|
||||
base := filepath.Base(path)
|
||||
flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_NONBLOCK | unix.O_CLOEXEC
|
||||
fd := -1
|
||||
if want != kindFile {
|
||||
fd, err = unix.Openat(dir, base, flags|unix.O_DIRECTORY, 0)
|
||||
if errors.Is(err, unix.ENOTDIR) && want == kindAny {
|
||||
fd, err = unix.Openat(dir, base, flags, 0)
|
||||
}
|
||||
} else {
|
||||
fd, err = unix.Openat(dir, base, flags, 0)
|
||||
}
|
||||
if err != nil {
|
||||
return -1, linkOr(path, home, path, "open", err)
|
||||
err = linkOr(path, home, path, "open", err)
|
||||
var link *LinkUnderHomeError
|
||||
if want == kindDir && !errors.As(err, &link) && errors.Is(err, unix.ENOTDIR) {
|
||||
return -1, fmt.Errorf("%s is not a directory where a directory was expected: below a home it is left alone", path)
|
||||
}
|
||||
return -1, err
|
||||
}
|
||||
if err := judgeOpened(fd, path, want); err != nil {
|
||||
unix.Close(fd)
|
||||
return -1, err
|
||||
}
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// judgeOpened is openUnderAs's verdict on what the descriptor holds.
|
||||
func judgeOpened(fd int, path string, want int) error {
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstat(fd, &st); err != nil {
|
||||
return &os.PathError{Op: "fstat", Path: path, Err: err}
|
||||
}
|
||||
switch st.Mode & unix.S_IFMT {
|
||||
case unix.S_IFDIR:
|
||||
if want == kindFile {
|
||||
return fmt.Errorf("%s is a directory where a file was expected: below a home it is left alone", path)
|
||||
}
|
||||
case unix.S_IFREG:
|
||||
if want == kindDir {
|
||||
return fmt.Errorf("%s is a file where a directory was expected: below a home it is left alone", path)
|
||||
}
|
||||
if st.Nlink > 1 {
|
||||
return &HardLinkUnderHomeError{Path: path, Links: uint64(st.Nlink)}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s is neither a file nor a directory: below a home it is left alone", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chmodUnder(home, path string, mode os.FileMode) error {
|
||||
fd, err := openUnder(home, path)
|
||||
return chmodUnderAs(home, path, mode, kindAny)
|
||||
}
|
||||
|
||||
func chmodUnderAs(home, path string, mode os.FileMode, want int) error {
|
||||
fd, err := openUnderAs(home, path, want)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -93,7 +156,7 @@ func chownUnder(home, path string, uid, gid int) error {
|
||||
}
|
||||
|
||||
func readUnder(home, path string) ([]byte, error) {
|
||||
fd, err := openUnder(home, path)
|
||||
fd, err := openUnderAs(home, path, kindFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ func chmodUnder(home, path string, mode os.FileMode) error {
|
||||
return os.Chmod(path, mode)
|
||||
}
|
||||
|
||||
func chmodUnderAs(home, path string, mode os.FileMode, _ int) error {
|
||||
return chmodUnder(home, path, mode)
|
||||
}
|
||||
|
||||
const kindDir = 1
|
||||
|
||||
func chownUnder(home, path string, uid, gid int) error {
|
||||
if err := refuseLinksUnderHome(path); err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user