| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- // 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
- }
|