296 lines
6.6 KiB
Go
296 lines
6.6 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly
|
|
|
|
package tailssh
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"tailscale.com/types/logger"
|
|
)
|
|
|
|
func TestDropPrivileges(t *testing.T) {
|
|
type SubprocInput struct {
|
|
UID int
|
|
GID int
|
|
AdditionalGroups []int
|
|
}
|
|
type SubprocOutput struct {
|
|
UID int
|
|
GID int
|
|
EUID int
|
|
EGID int
|
|
AdditionalGroups []int
|
|
}
|
|
|
|
if v := os.Getenv("TS_TEST_DROP_PRIVILEGES_CHILD"); v != "" {
|
|
t.Logf("in child process")
|
|
|
|
var input SubprocInput
|
|
if err := json.Unmarshal([]byte(v), &input); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Get a handle to our provided JSON file before dropping privs.
|
|
f := os.NewFile(3, "out.json")
|
|
|
|
// We're in our subprocess; actually drop privileges now.
|
|
dropPrivileges(t.Logf, input.UID, input.GID, input.AdditionalGroups)
|
|
|
|
additional, _ := syscall.Getgroups()
|
|
|
|
// Print our IDs
|
|
json.NewEncoder(f).Encode(SubprocOutput{
|
|
UID: os.Getuid(),
|
|
GID: os.Getgid(),
|
|
EUID: os.Geteuid(),
|
|
EGID: os.Getegid(),
|
|
AdditionalGroups: additional,
|
|
})
|
|
|
|
// Close output file to ensure that it's flushed to disk before we exit
|
|
f.Close()
|
|
|
|
// Always exit the process now that we have a different
|
|
// UID/GID/etc.; we don't want the Go test framework to try and
|
|
// clean anything up, since it might no longer have access.
|
|
os.Exit(0)
|
|
}
|
|
|
|
if os.Getuid() != 0 {
|
|
t.Skip("test only works when run as root")
|
|
}
|
|
|
|
rerunSelf := func(t *testing.T, input SubprocInput) []byte {
|
|
fpath := filepath.Join(t.TempDir(), "out.json")
|
|
outf, err := os.Create(fpath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
inputb, err := json.Marshal(input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cmd := exec.Command(os.Args[0], "-test.v", "-test.run", "^"+regexp.QuoteMeta(t.Name())+"$")
|
|
cmd.Env = append(os.Environ(), "TS_TEST_DROP_PRIVILEGES_CHILD="+string(inputb))
|
|
cmd.ExtraFiles = []*os.File{outf}
|
|
cmd.Stdout = logger.FuncWriter(logger.WithPrefix(t.Logf, "child: "))
|
|
cmd.Stderr = logger.FuncWriter(logger.WithPrefix(t.Logf, "child: "))
|
|
if err := cmd.Run(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
outf.Close()
|
|
|
|
jj, err := os.ReadFile(fpath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return jj
|
|
}
|
|
|
|
// We want to ensure we're not colliding with existing users; find some
|
|
// unused UIDs and GIDs for the tests we run.
|
|
uid1 := findUnusedUID(t)
|
|
gid1 := findUnusedGID(t)
|
|
gid2 := findUnusedGID(t, gid1)
|
|
gid3 := findUnusedGID(t, gid1, gid2)
|
|
|
|
// For some tests, we want a UID/GID pair with the same numerical
|
|
// value; this finds one.
|
|
uidgid1 := findUnusedUIDGID(t, uid1, gid1, gid2, gid3)
|
|
|
|
t.Logf("uid1=%d gid1=%d gid2=%d gid3=%d uidgid1=%d",
|
|
uid1, gid1, gid2, gid3, uidgid1)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
uid int
|
|
gid int
|
|
additionalGroups []int
|
|
}{
|
|
{
|
|
name: "all_different_values",
|
|
uid: uid1,
|
|
gid: gid1,
|
|
additionalGroups: []int{gid2, gid3},
|
|
},
|
|
{
|
|
name: "no_additional_groups",
|
|
uid: uid1,
|
|
gid: gid1,
|
|
additionalGroups: []int{},
|
|
},
|
|
// This is a regression test for the following bug, triggered
|
|
// on Darwin & FreeBSD:
|
|
// https://github.com/tailscale/tailscale/issues/7616
|
|
{
|
|
name: "same_values",
|
|
uid: uidgid1,
|
|
gid: uidgid1,
|
|
additionalGroups: []int{uidgid1},
|
|
},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
subprocOut := rerunSelf(t, SubprocInput{
|
|
UID: tt.uid,
|
|
GID: tt.gid,
|
|
AdditionalGroups: tt.additionalGroups,
|
|
})
|
|
|
|
var out SubprocOutput
|
|
if err := json.Unmarshal(subprocOut, &out); err != nil {
|
|
t.Logf("%s", subprocOut)
|
|
t.Fatal(err)
|
|
}
|
|
t.Logf("output: %+v", out)
|
|
|
|
if out.UID != tt.uid {
|
|
t.Errorf("got uid %d; want %d", out.UID, tt.uid)
|
|
}
|
|
if out.GID != tt.gid {
|
|
t.Errorf("got gid %d; want %d", out.GID, tt.gid)
|
|
}
|
|
if out.EUID != tt.uid {
|
|
t.Errorf("got euid %d; want %d", out.EUID, tt.uid)
|
|
}
|
|
if out.EGID != tt.gid {
|
|
t.Errorf("got egid %d; want %d", out.EGID, tt.gid)
|
|
}
|
|
|
|
// On FreeBSD and Darwin, the set of additional groups
|
|
// is prefixed with the egid; handle that case by
|
|
// modifying our expected set.
|
|
wantGroups := make(map[int]bool)
|
|
for _, id := range tt.additionalGroups {
|
|
wantGroups[id] = true
|
|
}
|
|
if runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" {
|
|
wantGroups[tt.gid] = true
|
|
}
|
|
|
|
gotGroups := make(map[int]bool)
|
|
for _, id := range out.AdditionalGroups {
|
|
gotGroups[id] = true
|
|
}
|
|
|
|
if !reflect.DeepEqual(gotGroups, wantGroups) {
|
|
t.Errorf("got additional groups %+v; want %+v", gotGroups, wantGroups)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func findUnusedUID(t *testing.T, not ...int) int {
|
|
for i := 1000; i < 65535; i++ {
|
|
// Skip UIDs that might be valid
|
|
if maybeValidUID(i) {
|
|
continue
|
|
}
|
|
|
|
// Skip UIDs that we're avoiding
|
|
if slices.Contains(not, i) {
|
|
continue
|
|
}
|
|
|
|
// Not a valid UID, not one we're avoiding... all good!
|
|
return i
|
|
}
|
|
|
|
t.Fatalf("unable to find an unused UID")
|
|
return -1
|
|
}
|
|
|
|
func findUnusedGID(t *testing.T, not ...int) int {
|
|
for i := 1000; i < 65535; i++ {
|
|
if maybeValidGID(i) {
|
|
continue
|
|
}
|
|
|
|
// Skip GIDs that we're avoiding
|
|
if slices.Contains(not, i) {
|
|
continue
|
|
}
|
|
|
|
// Not a valid GID, not one we're avoiding... all good!
|
|
return i
|
|
}
|
|
|
|
t.Fatalf("unable to find an unused GID")
|
|
return -1
|
|
}
|
|
|
|
func findUnusedUIDGID(t *testing.T, not ...int) int {
|
|
for i := 1000; i < 65535; i++ {
|
|
if maybeValidUID(i) || maybeValidGID(i) {
|
|
continue
|
|
}
|
|
|
|
// Skip IDs that we're avoiding
|
|
if slices.Contains(not, i) {
|
|
continue
|
|
}
|
|
|
|
// Not a valid ID, not one we're avoiding... all good!
|
|
return i
|
|
}
|
|
|
|
t.Fatalf("unable to find an unused UID/GID pair")
|
|
return -1
|
|
}
|
|
|
|
func maybeValidUID(id int) bool {
|
|
_, err := user.LookupId(strconv.Itoa(id))
|
|
if err == nil {
|
|
return true
|
|
}
|
|
|
|
var u1 user.UnknownUserIdError
|
|
if errors.As(err, &u1) {
|
|
return false
|
|
}
|
|
var u2 user.UnknownUserError
|
|
if errors.As(err, &u2) {
|
|
return false
|
|
}
|
|
|
|
// Some other error; might be valid
|
|
return true
|
|
}
|
|
|
|
func maybeValidGID(id int) bool {
|
|
_, err := user.LookupGroupId(strconv.Itoa(id))
|
|
if err == nil {
|
|
return true
|
|
}
|
|
|
|
var u1 user.UnknownGroupIdError
|
|
if errors.As(err, &u1) {
|
|
return false
|
|
}
|
|
var u2 user.UnknownGroupError
|
|
if errors.As(err, &u2) {
|
|
return false
|
|
}
|
|
|
|
// Some other error; might be valid
|
|
return true
|
|
}
|