mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-01 14:05:39 +00:00
4b525fdda0
Instead of changing the working directory before launching the incubator process, this now just changes the working directory after dropping privileges, at which point we're more likely to be able to enter the user's home directory since we're running as the user. For paths that use the 'login' or 'su -l' commands, those already take care of changing the working directory to the user's home directory. Fixes #13120 Signed-off-by: Percy Wegmann <percy@tailscale.com>
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 TestDoDropPrivileges(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.
|
|
doDropPrivileges(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
|
|
}
|