Follow no link below a home as root, and judge more ways to root
An account could replace ~/.claude with a link to /etc and have the node-engine chown, chmod or write through it on the next apply. Below a person's or an agent's home every component is now opened without following a link, and a link refuses the resource in words. The root judge also reads doas and polkit rules, the container runtimes' sockets with their ACLs, ACLs on the secrets, and setuid-root programs no package owns, in the C locale; Judged and NotJudged write down exactly what it covers (hq ADR 0266 review).
This commit is contained in:
+32
-8
@@ -918,6 +918,13 @@ type Unseal func(sealed string) ([]byte, error)
|
||||
func applyOne(ctx context.Context, sys system.System, r declaration.Resource, run Runner,
|
||||
changed map[string]bool, in inputs, previous store.Applied,
|
||||
unseal Unseal, keepFound Keep) (Outcome, error) {
|
||||
// Nothing below a home is touched through a link an account put there (novox/hq ADR 0266).
|
||||
switch r.(type) {
|
||||
case *declaration.Directory, *declaration.File, *declaration.Archive:
|
||||
if err := refuseLinksUnderHome(r.Target()); err != nil {
|
||||
return begin(r), err
|
||||
}
|
||||
}
|
||||
switch res := r.(type) {
|
||||
case *declaration.Directory:
|
||||
return applyDirectory(res)
|
||||
@@ -972,7 +979,7 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) {
|
||||
return out, err
|
||||
}
|
||||
|
||||
before, err := os.Stat(r.Path)
|
||||
before, err := statPath(r.Path)
|
||||
existed := err == nil
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return out, err
|
||||
@@ -989,12 +996,12 @@ 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 := os.Chmod(r.Path, mode); err != nil {
|
||||
if err := chmodPath(r.Path, mode); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Read back.
|
||||
after, err := os.Stat(r.Path)
|
||||
after, err := statPath(r.Path)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("made %s and cannot stat it: %w", r.Path, err)
|
||||
}
|
||||
@@ -1136,7 +1143,7 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
|
||||
return out, err
|
||||
}
|
||||
|
||||
existing, readErr := os.ReadFile(r.Path)
|
||||
existing, readErr := readPath(r.Path)
|
||||
existed := readErr == nil
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
return out, readErr
|
||||
@@ -1157,7 +1164,7 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
|
||||
var beforeMode os.FileMode
|
||||
beforeOwner := ""
|
||||
if existed {
|
||||
if info, err := os.Stat(r.Path); err == nil {
|
||||
if info, err := statPath(r.Path); err == nil {
|
||||
beforeMode = info.Mode().Perm()
|
||||
if uid, gid, ok := ownerOf(info); ok {
|
||||
beforeOwner = fmt.Sprintf("%d:%d", uid, gid)
|
||||
@@ -1189,20 +1196,20 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
|
||||
return out, err
|
||||
}
|
||||
} else if !modeSame {
|
||||
if err := os.Chmod(r.Path, mode); err != nil {
|
||||
if err := chmodPath(r.Path, mode); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
|
||||
// Read back — the file, not the call that wrote it.
|
||||
written, err := os.ReadFile(r.Path)
|
||||
written, err := readPath(r.Path)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("wrote %s and cannot read it back: %w", r.Path, err)
|
||||
}
|
||||
if string(written) != content {
|
||||
return out, fmt.Errorf("%s does not contain what was declared after writing it", r.Path)
|
||||
}
|
||||
info, err := os.Stat(r.Path)
|
||||
info, err := statPath(r.Path)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -1260,7 +1267,17 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
|
||||
//
|
||||
// A reader of a managed file must never see half of one. The mesh's own configuration is read
|
||||
// by daemons that reload on change, so a torn write is a service reading a truncated config.
|
||||
//
|
||||
// Below a home it is written through its directory's descriptor, following no link (home_links.go).
|
||||
func writeAtomically(path string, content []byte, mode os.FileMode) error {
|
||||
if home := homeAbove(path); home != "" {
|
||||
return writeUnder(home, path, content, mode)
|
||||
}
|
||||
return writeAtomicallyByPath(path, content, mode)
|
||||
}
|
||||
|
||||
// writeAtomicallyByPath is writeAtomically for a path below no home.
|
||||
func writeAtomicallyByPath(path string, content []byte, mode os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".mesh-host-*")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1610,6 +1627,13 @@ func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service,
|
||||
func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
||||
made map[string]bool) (string, string, error) {
|
||||
switch declaration.Type(a.Type) {
|
||||
case declaration.TypeDirectory, declaration.TypeFile, declaration.TypeArchive:
|
||||
// Removal below a home follows no link an account put there either (novox/hq ADR 0266).
|
||||
if err := refuseLinksUnderHome(a.Target); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
switch declaration.Type(a.Type) {
|
||||
case declaration.TypeDirectory:
|
||||
// **A directory with anything left in it is kept, and that is the rule that protects
|
||||
// data.** Everything the mesh put inside is itself a declared resource, and orphans are
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package apply
|
||||
|
||||
// A link under a person's home is never followed as root (novox/hq ADR 0266, the review of 2026-10-08).
|
||||
//
|
||||
// The node-engine runs as root and places files and directories under homes: the operator's shell
|
||||
// configuration, the agent's `~/.claude`. Everything below a home is the account's to change. An account that
|
||||
// replaces `~/.claude` with a symbolic link to /etc, between two applies, would have root change the owner and
|
||||
// mode of /etc, or write a file into it, on the next one — and on a machine whose agents run as an account of
|
||||
// their own, that account is exactly the one that must not become root.
|
||||
//
|
||||
// So for a path strictly below a home:
|
||||
//
|
||||
// - **a symbolic link in any component below the home, the path's own included, refuses the resource**, said
|
||||
// in words, and nothing is done to it — before anything is read, written, chowned or chmodded;
|
||||
// - **the owner and mode are set through a descriptor opened without following a link**, each component
|
||||
// opened from the one above it (homes_linux.go), so a link put in place after the check is not followed
|
||||
// either; a link that is itself the thing to own is owned as a link, never its target;
|
||||
// - **a file is written through its directory's descriptor**, made with O_EXCL|O_NOFOLLOW and renamed within
|
||||
// that directory, so neither the file nor a parent can be swapped for a link between check and write.
|
||||
//
|
||||
// The home itself is the machine's, made by root, and is followed as it is. A path outside every home is
|
||||
// handled exactly as before: a module's system paths may be links the machine set up (a resolver's file is).
|
||||
//
|
||||
// Which homes: every account the user database lists with a uid of 1000 or more, other than the overflow
|
||||
// account, and every home under /home. A service account's home under /var/lib is a module's own directory,
|
||||
// and is left as it was.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// passwdFile is the user database the homes are read from; a test names its own.
|
||||
var passwdFile = "/etc/passwd"
|
||||
|
||||
// homes is every home of a person's or an agent's account on this machine. A variable so a test can name the
|
||||
// homes it made.
|
||||
var homes = func() []string {
|
||||
f, err := os.Open(passwdFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
var out []string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Split(sc.Text(), ":")
|
||||
if len(fields) < 6 {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(fields[2])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
home := filepath.Clean(fields[5])
|
||||
if home == "/" || home == "." || home == "" {
|
||||
continue
|
||||
}
|
||||
if (uid >= 1000 && uid != 65534) || strings.HasPrefix(home, "/home/") {
|
||||
out = append(out, home)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// homeAbove is the home a path lies strictly below, the deepest when homes nest; "" when none.
|
||||
func homeAbove(path string) string {
|
||||
path = filepath.Clean(path)
|
||||
hs := homes()
|
||||
sort.Slice(hs, func(i, j int) bool { return len(hs[i]) > len(hs[j]) })
|
||||
for _, h := range hs {
|
||||
if strings.HasPrefix(path, h+string(os.PathSeparator)) {
|
||||
return h
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LinkUnderHomeError is a resource refused because a component below a home is a symbolic link.
|
||||
type LinkUnderHomeError struct {
|
||||
Path, Link, Home string
|
||||
}
|
||||
|
||||
func (e *LinkUnderHomeError) Error() string {
|
||||
return fmt.Sprintf("%s is a symbolic link inside the home %s, and the node-engine runs as root: it never "+
|
||||
"follows a link an account can put there, so nothing was done to %s (novox/hq ADR 0266). Make %s a "+
|
||||
"directory or file again, or ask why something replaced it", e.Link, e.Home, e.Path, e.Link)
|
||||
}
|
||||
|
||||
// refuseLinksUnderHome refuses a path below a home when any component below the home, its own included, is a
|
||||
// symbolic link. Components that do not exist yet end the walk: what is missing is made without following.
|
||||
func refuseLinksUnderHome(path string) error {
|
||||
home := homeAbove(path)
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(home, filepath.Clean(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
at := home
|
||||
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
|
||||
at = filepath.Join(at, part)
|
||||
info, err := os.Lstat(at)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return &LinkUnderHomeError{Path: path, Link: at, Home: home}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// statPath is os.Stat, except below a home, where it never follows a link.
|
||||
func statPath(path string) (os.FileInfo, error) {
|
||||
if homeAbove(path) != "" {
|
||||
return os.Lstat(path)
|
||||
}
|
||||
return os.Stat(path)
|
||||
}
|
||||
|
||||
// chmodPath sets a mode; below a home through a descriptor that followed no link.
|
||||
func chmodPath(path string, mode os.FileMode) error {
|
||||
home := homeAbove(path)
|
||||
if home == "" {
|
||||
return os.Chmod(path, mode)
|
||||
}
|
||||
return chmodUnder(home, path, mode)
|
||||
}
|
||||
|
||||
// chownPath sets an owner; below a home through a descriptor that followed no link, and a link itself is
|
||||
// owned as a link.
|
||||
func chownPath(path string, uid, gid int) error {
|
||||
home := homeAbove(path)
|
||||
if home == "" {
|
||||
return os.Chown(path, uid, gid)
|
||||
}
|
||||
if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return os.Lchown(path, uid, gid)
|
||||
}
|
||||
return chownUnder(home, path, uid, gid)
|
||||
}
|
||||
|
||||
// readPath reads a file; below a home without following a link.
|
||||
func readPath(path string) ([]byte, error) {
|
||||
home := homeAbove(path)
|
||||
if home == "" {
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
return readUnder(home, path)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//go:build linux
|
||||
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0266 (the review of 2026-10-08): below a home, the node-engine — root — follows no
|
||||
// symbolic link an account can put there: not to chown or chmod, not to write, not to make a directory.
|
||||
|
||||
// aLinkedHome is a home the test names as one, with `.claude` replaced by a link to a directory outside it,
|
||||
// as an account would to have root change /etc.
|
||||
func aLinkedHome(t *testing.T) (home, outside string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
home = filepath.Join(root, "home", "agent")
|
||||
outside = filepath.Join(root, "etc")
|
||||
for _, d := range []string{home, outside} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "shadow"), []byte("root's"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
was := homes
|
||||
homes = func() []string { return []string{home} }
|
||||
t.Cleanup(func() { homes = was })
|
||||
return home, outside
|
||||
}
|
||||
|
||||
func link(t *testing.T, target, at string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink(target, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func applyOneResource(t *testing.T, resource string) error {
|
||||
t.Helper()
|
||||
d := declare(t, resource)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func mustBeLinkRefusal(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var refused *LinkUnderHomeError
|
||||
if !errors.As(err, &refused) {
|
||||
t.Fatalf("refused as a link under a home, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "never follows a link") {
|
||||
t.Fatalf("said in words: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func modeOfPath(t *testing.T, p string) os.FileMode {
|
||||
t.Helper()
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return info.Mode().Perm()
|
||||
}
|
||||
|
||||
func TestADirectoryUnderAHomeThatIsALinkIsRefusedAndItsTargetUntouched(t *testing.T) {
|
||||
home, outside := aLinkedHome(t)
|
||||
link(t, outside, filepath.Join(home, ".claude"))
|
||||
err := applyOneResource(t, `{"id":"claude-code.agent-home","type":"directory","path":"`+
|
||||
filepath.Join(home, ".claude")+`","mode":"0700"}`)
|
||||
mustBeLinkRefusal(t, err)
|
||||
if m := modeOfPath(t, outside); m != 0o755 {
|
||||
t.Fatalf("the link's target was chmodded to %o", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileThroughALinkedParentUnderAHomeIsRefused(t *testing.T) {
|
||||
home, outside := aLinkedHome(t)
|
||||
link(t, outside, filepath.Join(home, ".claude"))
|
||||
err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+filepath.Join(home, ".claude", "shadow")+
|
||||
`","content":"the mesh's\n","mode":"0644"}`)
|
||||
mustBeLinkRefusal(t, err)
|
||||
if got, _ := os.ReadFile(filepath.Join(outside, "shadow")); string(got) != "root's" {
|
||||
t.Fatalf("written through the link: %q", got)
|
||||
}
|
||||
if m := modeOfPath(t, filepath.Join(outside, "shadow")); m != 0o600 {
|
||||
t.Fatalf("chmodded through the link: %o", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileThatIsItselfALinkUnderAHomeIsRefused(t *testing.T) {
|
||||
home, outside := aLinkedHome(t)
|
||||
link(t, filepath.Join(outside, "shadow"), filepath.Join(home, ".zshrc"))
|
||||
err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+filepath.Join(home, ".zshrc")+
|
||||
`","content":"x\n"}`)
|
||||
mustBeLinkRefusal(t, err)
|
||||
if got, _ := os.ReadFile(filepath.Join(outside, "shadow")); string(got) != "root's" {
|
||||
t.Fatalf("written through the link: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADirectoryAndAFileUnderAHomeAreMadeAsBefore(t *testing.T) {
|
||||
home, _ := aLinkedHome(t)
|
||||
dir := filepath.Join(home, ".claude")
|
||||
if err := applyOneResource(t, `{"id":"m.d","type":"directory","path":"`+dir+`","mode":"0700"}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := modeOfPath(t, dir); m != 0o700 {
|
||||
t.Fatalf("mode %o", m)
|
||||
}
|
||||
file := filepath.Join(home, ".config", "mesh", "env.sh")
|
||||
if err := applyOneResource(t, `{"id":"m.f","type":"file","path":"`+file+`","content":"A=1\n","mode":"0640"}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := os.ReadFile(file); string(got) != "A=1\n" || modeOfPath(t, file) != 0o640 {
|
||||
t.Fatalf("written %q mode %o", got, modeOfPath(t, file))
|
||||
}
|
||||
}
|
||||
|
||||
// The descriptor half: a link put in place after any check is still not followed.
|
||||
func TestTheDescriptorCallsFollowNoLinkBelowAHome(t *testing.T) {
|
||||
home, outside := aLinkedHome(t)
|
||||
link(t, outside, filepath.Join(home, ".claude"))
|
||||
mustBeLinkRefusal(t, chmodPath(filepath.Join(home, ".claude"), 0o777))
|
||||
mustBeLinkRefusal(t, chmodPath(filepath.Join(home, ".claude", "shadow"), 0o777))
|
||||
if m := modeOfPath(t, filepath.Join(outside, "shadow")); m != 0o600 {
|
||||
t.Fatalf("chmodded through the link: %o", m)
|
||||
}
|
||||
mustBeLinkRefusal(t, writeAtomically(filepath.Join(home, ".claude", "new"), []byte("x"), 0o644))
|
||||
if _, err := os.Stat(filepath.Join(outside, "new")); !os.IsNotExist(err) {
|
||||
t.Fatal("written through the link")
|
||||
}
|
||||
if _, err := makeDirsSaying(filepath.Join(home, ".claude", "a", "b"), 0o755, ""); err == nil {
|
||||
t.Fatal("made directories through the link")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "a")); !os.IsNotExist(err) {
|
||||
t.Fatal("made a directory through the link")
|
||||
}
|
||||
if _, err := readPath(filepath.Join(home, ".claude", "shadow")); err == nil {
|
||||
t.Fatal("read through the link")
|
||||
}
|
||||
me := os.Getuid()
|
||||
// A link itself is owned as a link, never its target.
|
||||
if err := chownPath(filepath.Join(home, ".claude"), me, os.Getgid()); err != nil {
|
||||
t.Fatalf("a link is owned as a link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovalThroughALinkUnderAHomeIsRefused(t *testing.T) {
|
||||
home, outside := aLinkedHome(t)
|
||||
link(t, outside, filepath.Join(home, ".claude"))
|
||||
_, _, err := remove(context.Background(), archHost(t), store.Applied{ID: "m.f", Type: "file",
|
||||
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil)
|
||||
mustBeLinkRefusal(t, err)
|
||||
if _, err := os.Stat(filepath.Join(outside, "shadow")); err != nil {
|
||||
t.Fatal("removed through the link")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPathOutsideEveryHomeIsHandledAsBefore(t *testing.T) {
|
||||
_, outside := aLinkedHome(t)
|
||||
elsewhere := filepath.Join(filepath.Dir(outside), "srv")
|
||||
if err := os.MkdirAll(elsewhere, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link(t, outside, filepath.Join(elsewhere, "linked"))
|
||||
if homeAbove(filepath.Join(elsewhere, "linked", "x")) != "" {
|
||||
t.Fatal("not below a home")
|
||||
}
|
||||
if err := refuseLinksUnderHome(filepath.Join(elsewhere, "linked", "x")); err != nil {
|
||||
t.Fatal("a system path may be a link the machine set up; only a home's are refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomesAreAPersonsOrAnAgentsNotAServicesOrRoots(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
passwd := filepath.Join(dir, "passwd")
|
||||
if err := os.WriteFile(passwd, []byte("root:x:0:0::/root:/bin/bash\n"+
|
||||
"postgres:x:968:968::/var/lib/postgres:/usr/bin/nologin\n"+
|
||||
"nobody:x:65534:65534::/:/usr/bin/nologin\n"+
|
||||
"operator:x:1000:1000::/home/operator:/bin/zsh\n"+
|
||||
"agent:x:1001:1001::/home/agent:/bin/bash\n"+
|
||||
"svc:x:990:990::/home/svc:/usr/bin/nologin\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
was := passwdFile
|
||||
passwdFile = passwd
|
||||
t.Cleanup(func() { passwdFile = was })
|
||||
got := strings.Join(homes(), ",")
|
||||
if got != "/home/operator,/home/agent,/home/svc" {
|
||||
t.Fatalf("homes %s", got)
|
||||
}
|
||||
if homeAbove("/home/agent/.claude") != "/home/agent" || homeAbove("/home/agent") != "" ||
|
||||
homeAbove("/var/lib/postgres/data") != "" || homeAbove("/root/.ssh") != "" {
|
||||
t.Fatal("strictly below a person's or an agent's home, and nothing else")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//go:build linux
|
||||
|
||||
package apply
|
||||
|
||||
// The descriptor half of home_links.go: below a home, every component is opened from the one above it with
|
||||
// O_NOFOLLOW, so a link is refused by the kernel at the moment of use, not only at the check before it.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// openDirUnder opens the directory rel names below home, following no link below the home.
|
||||
func openDirUnder(home, dir string) (int, error) {
|
||||
rel, err := filepath.Rel(home, filepath.Clean(dir))
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return -1, fmt.Errorf("%s is not below %s", dir, home)
|
||||
}
|
||||
fd, err := unix.Open(home, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return -1, &os.PathError{Op: "open", Path: home, Err: err}
|
||||
}
|
||||
if rel == "." {
|
||||
return fd, nil
|
||||
}
|
||||
at := home
|
||||
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
|
||||
at = filepath.Join(at, part)
|
||||
next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
unix.Close(fd)
|
||||
if err != nil {
|
||||
return -1, linkOr(at, home, dir, "open", err)
|
||||
}
|
||||
fd = next
|
||||
}
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// linkOr says a refused link in the mesh's words, and any other failure as the system's.
|
||||
func linkOr(at, home, path, op string, err error) error {
|
||||
if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
|
||||
if info, lerr := os.Lstat(at); lerr == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return &LinkUnderHomeError{Path: path, Link: at, Home: home}
|
||||
}
|
||||
}
|
||||
return &os.PathError{Op: op, Path: at, Err: err}
|
||||
}
|
||||
|
||||
// openUnder opens the file or directory at path below home, following no link, for its metadata.
|
||||
func openUnder(home, path string) (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)
|
||||
if err != nil {
|
||||
return -1, linkOr(path, home, path, "open", err)
|
||||
}
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
func chmodUnder(home, path string, mode os.FileMode) error {
|
||||
fd, err := openUnder(home, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
|
||||
return &os.PathError{Op: "chmod", Path: path, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chownUnder(home, path string, uid, gid int) error {
|
||||
fd, err := openUnder(home, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
if err := unix.Fchown(fd, uid, gid); err != nil {
|
||||
return &os.PathError{Op: "chown", Path: path, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readUnder(home, path string) ([]byte, error) {
|
||||
fd, err := openUnder(home, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), path)
|
||||
defer f.Close()
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstat(fd, &st); err == nil && st.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
return nil, fmt.Errorf("%s is not a regular file", path)
|
||||
}
|
||||
return io.ReadAll(f)
|
||||
}
|
||||
|
||||
// mkdirAllUnder makes the directories missing below home down to dir, each made in its parent's descriptor
|
||||
// and opened without following a link; answers those it made, deepest first, as makeDirsSaying does.
|
||||
func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) {
|
||||
rel, err := filepath.Rel(home, filepath.Clean(dir))
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return nil, fmt.Errorf("%s is not below %s", dir, home)
|
||||
}
|
||||
fd, err := unix.Open(home, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, &os.PathError{Op: "open", Path: home, Err: err}
|
||||
}
|
||||
defer func() { unix.Close(fd) }()
|
||||
var made []string
|
||||
if rel == "." {
|
||||
return nil, nil
|
||||
}
|
||||
at := home
|
||||
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
|
||||
at = filepath.Join(at, part)
|
||||
if err := unix.Mkdirat(fd, part, uint32(mode.Perm())); err == nil {
|
||||
made = append([]string{at}, made...)
|
||||
} else if !errors.Is(err, unix.EEXIST) {
|
||||
return made, &os.PathError{Op: "mkdir", Path: at, Err: err}
|
||||
}
|
||||
next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return made, linkOr(at, home, dir, "open", err)
|
||||
}
|
||||
unix.Close(fd)
|
||||
fd = next
|
||||
}
|
||||
return made, nil
|
||||
}
|
||||
|
||||
// writeUnder writes a file below home atomically, through its directory's descriptor: made O_EXCL|O_NOFOLLOW
|
||||
// under a name of its own, given its mode, and renamed over the file within that directory.
|
||||
func writeUnder(home, path string, content []byte, mode os.FileMode) error {
|
||||
dir, err := openDirUnder(home, filepath.Dir(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Close(dir)
|
||||
name := ".mesh-host-" + strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.Itoa(os.Getpid())
|
||||
fd, err := unix.Openat(dir, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600)
|
||||
if err != nil {
|
||||
return &os.PathError{Op: "create", Path: filepath.Join(filepath.Dir(path), name), Err: err}
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), name)
|
||||
_, werr := f.Write(content)
|
||||
if werr == nil {
|
||||
werr = f.Sync()
|
||||
}
|
||||
if werr == nil {
|
||||
if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil {
|
||||
werr = &os.PathError{Op: "chmod", Path: path, Err: err}
|
||||
}
|
||||
}
|
||||
if cerr := f.Close(); werr == nil {
|
||||
werr = cerr
|
||||
}
|
||||
if werr == nil {
|
||||
if err := unix.Renameat(dir, name, dir, filepath.Base(path)); err != nil {
|
||||
werr = &os.PathError{Op: "rename", Path: path, Err: err}
|
||||
}
|
||||
}
|
||||
if werr != nil {
|
||||
_ = unix.Unlinkat(dir, name, 0)
|
||||
}
|
||||
return werr
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build !linux
|
||||
|
||||
package apply
|
||||
|
||||
// Where there is no openat with O_NOFOLLOW to rely on, the check before use is all: a link below a home is
|
||||
// refused, and the call is made by path. The node-engine runs on Linux; this keeps the package building.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func chmodUnder(home, path string, mode os.FileMode) error {
|
||||
if err := refuseLinksUnderHome(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(path, mode)
|
||||
}
|
||||
|
||||
func chownUnder(home, path string, uid, gid int) error {
|
||||
if err := refuseLinksUnderHome(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Lchown(path, uid, gid)
|
||||
}
|
||||
|
||||
func readUnder(home, path string) ([]byte, error) {
|
||||
if err := refuseLinksUnderHome(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func mkdirAllUnder(home, dir string, mode os.FileMode) ([]string, error) {
|
||||
if err := refuseLinksUnderHome(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var made []string
|
||||
for d := filepath.Clean(dir); d != home; d = filepath.Dir(d) {
|
||||
if _, err := os.Lstat(d); err == nil {
|
||||
break
|
||||
}
|
||||
made = append(made, d)
|
||||
}
|
||||
return made, os.MkdirAll(dir, mode)
|
||||
}
|
||||
|
||||
func writeUnder(home, path string, content []byte, mode os.FileMode) error {
|
||||
if err := refuseLinksUnderHome(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomicallyByPath(path, content, mode)
|
||||
}
|
||||
+30
-7
@@ -302,7 +302,7 @@ func own(path, owner string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s should belong to %q: %w", path, owner, err)
|
||||
}
|
||||
if err := os.Chown(path, uid, gid); err != nil {
|
||||
if err := chownPath(path, uid, gid); err != nil {
|
||||
return fmt.Errorf("cannot give %s to %q: %w", path, owner, err)
|
||||
}
|
||||
return nil
|
||||
@@ -359,7 +359,7 @@ func ownedBy(path, owner string) (bool, error) {
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
info, err := statPath(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -392,6 +392,15 @@ func makeDirs(dir string, mode os.FileMode, owner string) error {
|
||||
// can take away on removal the parents it made to reach its directory (novox/hq issue 162).
|
||||
func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error) {
|
||||
var made []string
|
||||
if below := homeAbove(dir); below != "" {
|
||||
// Below a home, each directory is made in its parent's descriptor and none is reached through a link
|
||||
// (novox/hq ADR 0266).
|
||||
var err error
|
||||
if made, err = mkdirAllUnder(below, dir, mode); err != nil {
|
||||
return made, err
|
||||
}
|
||||
return made, giveMade(made, owner)
|
||||
}
|
||||
for d := filepath.Clean(dir); ; d = filepath.Dir(d) {
|
||||
if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) {
|
||||
break
|
||||
@@ -404,14 +413,19 @@ func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error
|
||||
if err := os.MkdirAll(dir, mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return made, giveMade(made, owner)
|
||||
}
|
||||
|
||||
// giveMade gives the directories the host made inside the owner's home to the owner.
|
||||
func giveMade(made []string, owner string) error {
|
||||
if owner == "" || len(made) == 0 {
|
||||
return made, nil
|
||||
return nil
|
||||
}
|
||||
home, err := homeOf(owner)
|
||||
if err != nil || home == "" {
|
||||
// A numeric owner — a container's user — has no home, and a name the machine does not
|
||||
// know fails where the target is given to it. Either way nothing here is a home's.
|
||||
return made, nil
|
||||
return nil
|
||||
}
|
||||
home = filepath.Clean(home)
|
||||
for _, d := range made {
|
||||
@@ -419,10 +433,10 @@ func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error
|
||||
continue
|
||||
}
|
||||
if err := ownMade(d, owner); err != nil {
|
||||
return made, err
|
||||
return err
|
||||
}
|
||||
}
|
||||
return made, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// homeOf is an owner's home from the user database, and ownMade gives a directory the host made to
|
||||
@@ -444,10 +458,19 @@ func ownAll(root, owner string) error {
|
||||
if owner == "" {
|
||||
return nil
|
||||
}
|
||||
return filepath.Walk(root, func(path string, _ os.FileInfo, err error) error {
|
||||
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A link in the tree is owned as a link: chown follows one, and root must never give away what it
|
||||
// points at (novox/hq ADR 0266).
|
||||
if info != nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
uid, gid, ierr := idsOf(owner)
|
||||
if ierr != nil {
|
||||
return fmt.Errorf("%s should belong to %q: %w", path, owner, ierr)
|
||||
}
|
||||
return os.Lchown(path, uid, gid)
|
||||
}
|
||||
return own(path, owner)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user