bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// faTestUser finds a real, non-root, non-nobody account with a home directory
|
||||
// on this box — the identity a granted folder would be owned by. "" when the
|
||||
// box has none (a bare container): the root-only tests skip.
|
||||
func faTestUser(t *testing.T) *user.User {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("/etc/passwd")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
f := strings.Split(line, ":")
|
||||
if len(f) < 7 {
|
||||
continue
|
||||
}
|
||||
uid, _ := strconv.Atoi(f[2])
|
||||
if uid < 1000 || uid == 65534 || f[0] == "nobody" {
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(f[5]); err != nil || !st.IsDir() {
|
||||
continue
|
||||
}
|
||||
if u, err := user.LookupId(f[2]); err == nil {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeTraversable opens t.TempDir's 0700 chain so a dropped child can reach
|
||||
// the leaf — what /var/lib/privatae/freeagent's 0755 base gives for real.
|
||||
func makeTraversable(t *testing.T, p string) {
|
||||
t.Helper()
|
||||
for d := p; d != "/" && d != "."; d = filepath.Dir(d) {
|
||||
if err := os.Chmod(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaResolveOwnerNonRootTakesNoDrop(t *testing.T) {
|
||||
if processIsRoot() {
|
||||
t.Skip("runs as a user")
|
||||
}
|
||||
o, err := faResolveOwner(t.TempDir(), t.TempDir())
|
||||
if err != nil || o == nil || o.drop {
|
||||
t.Fatalf("a non-root install has no privilege to shed: %+v %v", o, err)
|
||||
}
|
||||
if o.sysProcAttr() != nil {
|
||||
t.Fatal("no credential when not dropping")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaResolveOwnerRefusesRootNobodyAndUnknown(t *testing.T) {
|
||||
if !processIsRoot() {
|
||||
t.Skip("needs root to chown")
|
||||
}
|
||||
state := t.TempDir()
|
||||
// root-owned folder: refused, and the reason says root (uid 0 resolves —
|
||||
// "cannot resolve" would not have caught this)
|
||||
if _, err := faResolveOwner(t.TempDir(), state); err == nil || !strings.Contains(err.Error(), "root") {
|
||||
t.Fatalf("a root-owned folder must be refused with the reason: %v", err)
|
||||
}
|
||||
// nobody: refused (a shared account would hold the vault)
|
||||
d := t.TempDir()
|
||||
if err := os.Chown(d, 65534, 65534); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := faResolveOwner(d, state); err == nil || !strings.Contains(err.Error(), "nobody") {
|
||||
t.Fatalf("a nobody-owned folder must be refused with the reason: %v", err)
|
||||
}
|
||||
// an owner with no passwd entry: refused
|
||||
d = t.TempDir()
|
||||
orphan := 61234
|
||||
for ; orphan < 61300; orphan++ {
|
||||
if _, err := user.LookupId(strconv.Itoa(orphan)); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := os.Chown(d, orphan, orphan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := faResolveOwner(d, state); err == nil || !strings.Contains(err.Error(), "no account") {
|
||||
t.Fatalf("an unknown owner must be refused with the reason: %v", err)
|
||||
}
|
||||
// a symlinked root resolves to its TARGET's owner (Stat, not Lstat)
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink(d, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := faResolveOwner(link, state); err == nil || !strings.Contains(err.Error(), "no account") {
|
||||
t.Fatalf("a symlinked root must resolve to the target's owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaDropRunsTheChildAsTheFolderOwner(t *testing.T) {
|
||||
if !processIsRoot() {
|
||||
t.Skip("needs root to drop")
|
||||
}
|
||||
u := faTestUser(t)
|
||||
if u == nil {
|
||||
t.Skip("no ordinary user account on this box")
|
||||
}
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
|
||||
base := t.TempDir()
|
||||
makeTraversable(t, base)
|
||||
old := faStateBaseOverride
|
||||
faStateBaseOverride = base
|
||||
t.Cleanup(func() { faStateBaseOverride = old })
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Chown(root, uid, gid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// the store dir lands under the base, not under configDir
|
||||
dir, err := faStoreDir("/root/.config/privatae-bastion/dcn/anchor.host.privatae.ai", 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(dir, base) || !strings.HasSuffix(dir, "/anchor.host.privatae.ai/7") {
|
||||
t.Fatalf("root-install store dir must be <base>/<pairing>/<store>: %s", dir)
|
||||
}
|
||||
if st, _ := os.Stat(filepath.Dir(dir)); st.Mode().Perm() != 0o755 {
|
||||
t.Fatalf("the pairing slot must be traversable: %v", st.Mode())
|
||||
}
|
||||
if st, _ := os.Stat(dir); st.Mode().Perm() != 0o700 {
|
||||
t.Fatalf("the store leaf stays private: %v", st.Mode())
|
||||
}
|
||||
o, err := faResolveOwner(root, dir)
|
||||
if err != nil || !o.drop || int(o.uid) != uid || o.name != u.Username {
|
||||
t.Fatalf("owner must be the folder's owner: %+v %v", o, err)
|
||||
}
|
||||
if o.home != u.HomeDir {
|
||||
t.Fatalf("HOME must be the owner's home: %q", o.home)
|
||||
}
|
||||
// hand the state over; a file written by root afterwards is re-owned
|
||||
if err := os.Chown(dir, uid, gid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := filepath.Join(dir, "host.key")
|
||||
if err := atomicWrite(secret, []byte("k")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rd, err := faOpenOwned(dir, o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rd.Close()
|
||||
if err := faOwnTree(rd, ".", o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if su, _, _ := faPathOwner(secret); int(su) != uid {
|
||||
t.Fatal("the identity must belong to the owner")
|
||||
}
|
||||
if err := atomicWrite(secret, []byte("k2")); err != nil { // a rewrite: root-owned again
|
||||
t.Fatal(err)
|
||||
}
|
||||
if su, _, _ := faPathOwner(secret); su != 0 {
|
||||
t.Fatal("test premise: atomicWrite leaves a root-owned file")
|
||||
}
|
||||
if err := faOwn(o, secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if su, _, _ := faPathOwner(secret); int(su) != uid {
|
||||
t.Fatal("faOwn must re-own after every write")
|
||||
}
|
||||
|
||||
// the drop itself: a child started with the credential IS the owner, with
|
||||
// the owner's HOME and no supplementary groups from root
|
||||
inst := &faInstance{Dir: dir, owner: o}
|
||||
cmd := exec.Command("sh", "-c", "id -u; id -G; echo $HOME")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = faChildEnv(inst)
|
||||
cmd.SysProcAttr = o.sysProcAttr()
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("child as %s: %v\n%s", u.Username, err, out)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) != 3 || lines[0] != u.Uid {
|
||||
t.Fatalf("the child must run as uid %s:\n%s", u.Uid, out)
|
||||
}
|
||||
if strings.Contains(" "+lines[1]+" ", " 0 ") {
|
||||
t.Fatalf("the child must not carry root's groups: %q", lines[1])
|
||||
}
|
||||
if lines[2] != u.HomeDir {
|
||||
t.Fatalf("HOME must be the owner's, got %q", lines[2])
|
||||
}
|
||||
for _, e := range cmd.Env {
|
||||
if strings.HasPrefix(e, "XDG_CONFIG_HOME=") {
|
||||
t.Fatal("root's XDG_CONFIG_HOME must not pass through to a dropped child")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaPairingKey(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"/root/.config/privatae-bastion/dcn/anchor.host.privatae.ai": "anchor.host.privatae.ai",
|
||||
"/x/dcn/we ird/../name": "name",
|
||||
"/x/dcn/..": "default",
|
||||
} {
|
||||
if got := faPairingKey(in); got != want {
|
||||
t.Errorf("faPairingKey(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Root-side writes into the child's directories never follow a link the child
|
||||
// planted: the log fd, the manifest placement, the .agent directory itself.
|
||||
func TestFaRootSideWritesRefuseThePlantedLink(t *testing.T) {
|
||||
if !processIsRoot() {
|
||||
t.Skip("needs root: the attack is a user aiming root's write")
|
||||
}
|
||||
u := faTestUser(t)
|
||||
if u == nil {
|
||||
t.Skip("no ordinary user account on this box")
|
||||
}
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
o := &faOwner{drop: true, uid: uint32(uid), gid: uint32(gid), name: u.Username, home: u.HomeDir}
|
||||
|
||||
target := filepath.Join(t.TempDir(), "root-owned")
|
||||
if err := os.WriteFile(target, []byte("keep\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir() // the child's dir: owner-writable, so a link can appear in it
|
||||
if err := os.Chown(dir, uid, gid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// serve.log -> a root file. The open must refuse; the target is untouched.
|
||||
if err := os.Symlink(target, filepath.Join(dir, "serve.log")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f, err := faOpenLog(dir, "serve.log", o); err == nil {
|
||||
f.Close()
|
||||
t.Fatal("a log that is a link out of the child's dir must be refused")
|
||||
}
|
||||
// a hard link to a root file inside the dir: two links, refused
|
||||
if err := os.Link(target, filepath.Join(dir, "sweep.log")); err == nil {
|
||||
if f, err := faOpenLog(dir, "sweep.log", o); err == nil {
|
||||
f.Close()
|
||||
t.Fatal("a hard link to a root file must be refused")
|
||||
}
|
||||
}
|
||||
// a plain file is opened, owned by the child, and appendable
|
||||
f, err := faOpenLog(dir, "ok.log", o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
if su, _, _ := faPathOwner(filepath.Join(dir, "ok.log")); int(su) != uid {
|
||||
t.Fatal("the log must belong to the child")
|
||||
}
|
||||
|
||||
// manifest.yaml -> a root file. The placement drops the link and
|
||||
// writes a real file; the target keeps its bytes.
|
||||
if err := os.Symlink(target, filepath.Join(dir, "manifest.yaml")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rd, err := faOpenOwned(dir, o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rd.Close()
|
||||
if err := faOwnedWrite(rd, "manifest.yaml", []byte("manifest\n"), 0o600, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, _ := os.ReadFile(target); string(b) != "keep\n" {
|
||||
t.Fatalf("the root-owned target was written through the link: %q", b)
|
||||
}
|
||||
if st, err := os.Lstat(filepath.Join(dir, "manifest.yaml")); err != nil || !st.Mode().IsRegular() {
|
||||
t.Fatal("the manifest must be a plain file after placement")
|
||||
}
|
||||
if b, _ := rd.ReadFile("manifest.yaml"); string(b) != "manifest\n" {
|
||||
t.Fatal("the placed manifest must read back")
|
||||
}
|
||||
|
||||
// .agent -> a directory root owns. Mkdir must not write into it.
|
||||
folder := t.TempDir()
|
||||
if err := os.Chown(folder, uid, gid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elsewhere := t.TempDir()
|
||||
if err := os.Symlink(elsewhere, filepath.Join(folder, ".agent")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rf, err := faOpenOwned(folder, o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rf.Close()
|
||||
if err := faOwnedMkdir(rf, ".agent", o); err == nil {
|
||||
t.Fatal("a .agent that is a link out of the folder must be refused")
|
||||
}
|
||||
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
|
||||
t.Fatal("nothing may be written through the linked .agent")
|
||||
}
|
||||
// a folder that is itself a link to a root-owned directory is refused at
|
||||
// open — the path to a granted folder can be the user's to swap
|
||||
swapped := filepath.Join(t.TempDir(), "docs")
|
||||
if err := os.Symlink(elsewhere, swapped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r, err := faOpenOwned(swapped, o); err == nil {
|
||||
r.Close()
|
||||
t.Fatal("a granted folder resolving to a root-owned directory must be refused")
|
||||
}
|
||||
// a chown through the handle never follows a directory the child
|
||||
// swapped for a link out of the tree — the walk and Lchown resolve inside
|
||||
// the root, so the target keeps its owner
|
||||
if err := os.Mkdir(filepath.Join(folder, "real"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chown(filepath.Join(folder, "real"), uid, gid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "shadow"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Remove(filepath.Join(folder, ".agent"))
|
||||
if err := os.Symlink(outside, filepath.Join(folder, ".agent")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rf.Lchown(".agent/shadow", uid, gid); err == nil {
|
||||
t.Fatal("Lchown through a linked directory must be refused by the root handle")
|
||||
}
|
||||
if err := faOwnTree(rf, ".", o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if su, _, _ := faPathOwner(filepath.Join(outside, "shadow")); su != 0 {
|
||||
t.Fatal("a tree chown must not re-own anything outside the folder")
|
||||
}
|
||||
if su, _, _ := faPathOwner(filepath.Join(folder, "real")); int(su) != uid {
|
||||
t.Fatal("the real subtree must be re-owned")
|
||||
}
|
||||
|
||||
// the recipient is read from the root-private dir, never the child's
|
||||
priv := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(priv, "recipient"), []byte("age1x\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Symlink(target, filepath.Join(dir, "recipient"))
|
||||
if got := faRecipientOf(&faInstance{Dir: dir, PrivDir: priv}); got != "age1x" {
|
||||
t.Fatalf("recipient must come from the private dir, got %q", got)
|
||||
}
|
||||
|
||||
// an owner that cannot reach its folder is refused with the reason
|
||||
parent := t.TempDir() // 0700 root
|
||||
leaf := filepath.Join(parent, "leaf")
|
||||
if err := os.Mkdir(leaf, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chown(leaf, uid, gid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := faResolveOwner(leaf, dir); err == nil || !strings.Contains(err.Error(), "cannot reach") {
|
||||
t.Fatalf("a user-owned leaf under a root-only parent must be refused: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user