mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-13 18:29:07 +02:00
241 lines
8.1 KiB
Go
241 lines
8.1 KiB
Go
//go:build !windows && !ios && !android
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/getent"
|
|
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
|
)
|
|
|
|
// testAllowGroupPrincipal is a principal that resolves on any Unix host.
|
|
const testAllowGroupPrincipal = "gid:0"
|
|
|
|
func TestResolveAllowGroup_NumericGID(t *testing.T) {
|
|
for _, value := range []string{"0", "gid:0"} {
|
|
t.Run(value, func(t *testing.T) {
|
|
principal, err := resolveAllowGroup(value)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ipcauth.KindGID, principal.Kind)
|
|
assert.Equal(t, "gid:0", principal.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveAllowGroup_ByName(t *testing.T) {
|
|
// The name of this process's primary group, so the test does not assume
|
|
// what gid 0 is called: Linux says "root", macOS says "wheel".
|
|
gid := strconv.Itoa(os.Getgid())
|
|
group, err := getent.LookupGroupID(gid)
|
|
if err != nil {
|
|
t.Skipf("gid %s has no name on this host: %v", gid, err)
|
|
}
|
|
|
|
principal, err := resolveAllowGroup(group.Name)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "gid:"+gid, principal.String())
|
|
}
|
|
|
|
// Spellings of the same GID must collapse to one principal, otherwise
|
|
// checkAllowGroupSet reads them as a request for two groups and refuses.
|
|
func TestResolveAllowGroups_CanonicalisesGIDs(t *testing.T) {
|
|
resolved, err := resolveAllowGroups([]string{"gid:01", "gid:1", "1"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"gid:1"}, resolved)
|
|
}
|
|
|
|
func TestResolveAllowGroup_Rejects(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
}{
|
|
{name: "windows principal", value: "sid:S-1-5-32-544"},
|
|
{name: "unknown kind", value: "user:alice"},
|
|
{name: "non-numeric gid", value: "gid:wheel"},
|
|
{name: "negative gid", value: "gid:-1"},
|
|
{name: "unknown group", value: "no-such-group-08b1f0c4"},
|
|
// chown reads this as "leave the group alone", so applying it would
|
|
// leave the socket on whatever group it already had.
|
|
{name: "the unchanged-gid sentinel", value: "gid:4294967295"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := resolveAllowGroup(tc.value)
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A Unix socket carries one owning group, so a second one could not be
|
|
// enforced and must be refused rather than silently dropped.
|
|
func TestCheckAllowGroupSet_SingleGroupOnly(t *testing.T) {
|
|
assert.NoError(t, checkAllowGroupSet(nil))
|
|
assert.NoError(t, checkAllowGroupSet([]string{"gid:0"}))
|
|
assert.Error(t, checkAllowGroupSet([]string{"gid:0", "gid:1"}))
|
|
}
|
|
|
|
func TestApplySocketAccess(t *testing.T) {
|
|
// With nothing configured the socket already carries its final mode from
|
|
// the bind, so this must not touch the path at all: a chmod here is the one
|
|
// that could follow a symlink another account planted.
|
|
t.Run("no principals leaves the socket alone", func(t *testing.T) {
|
|
path := listenTestSocket(t)
|
|
before := socketMode(t, path)
|
|
|
|
require.NoError(t, applySocketAccess(path, nil))
|
|
assert.Equal(t, before, socketMode(t, path))
|
|
})
|
|
|
|
t.Run("a principal hands the socket to that group", func(t *testing.T) {
|
|
path := listenTestSocket(t)
|
|
// The process's own primary group: chown to any other group needs
|
|
// privileges the test does not have.
|
|
gid := os.Getgid()
|
|
|
|
require.NoError(t, applySocketAccess(path, []string{"gid:" + strconv.Itoa(gid)}))
|
|
assert.Equal(t, os.FileMode(0660), socketMode(t, path))
|
|
assert.Equal(t, uint32(gid), socketGID(t, path))
|
|
})
|
|
|
|
t.Run("a principal of another platform is refused", func(t *testing.T) {
|
|
path := listenTestSocket(t)
|
|
|
|
require.Error(t, applySocketAccess(path, []string{"sid:S-1-5-32-544"}))
|
|
})
|
|
|
|
t.Run("an unparseable gid is refused", func(t *testing.T) {
|
|
path := listenTestSocket(t)
|
|
|
|
require.Error(t, applySocketAccess(path, []string{"gid:wheel"}))
|
|
})
|
|
|
|
// A symlink standing where the listener put its socket is something another
|
|
// account substituted, and chowning it as root would hand its target away.
|
|
t.Run("a path that is not a socket is refused", func(t *testing.T) {
|
|
dir, err := os.MkdirTemp("", "nb-sock")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { assert.NoError(t, os.RemoveAll(dir)) })
|
|
|
|
target := filepath.Join(dir, "target")
|
|
require.NoError(t, os.WriteFile(target, []byte("not a socket"), 0600))
|
|
link := filepath.Join(dir, "d.sock")
|
|
require.NoError(t, os.Symlink(target, link))
|
|
|
|
gid := strconv.Itoa(os.Getgid())
|
|
require.Error(t, applySocketAccess(link, []string{"gid:" + gid}))
|
|
require.NoError(t, applySocketAccess(link, nil), "with nothing configured there is nothing to apply")
|
|
|
|
// Either way the substituted target keeps the mode it was created with.
|
|
info, err := os.Stat(target)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
|
|
})
|
|
|
|
// Restricting a socket in a directory other accounts can write to is
|
|
// refused: they can replace the entry between the check and the change.
|
|
t.Run("an untrusted socket directory is refused", func(t *testing.T) {
|
|
dir, err := os.MkdirTemp("", "nb-sock")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { assert.NoError(t, os.RemoveAll(dir)) })
|
|
require.NoError(t, os.Chmod(dir, 0777))
|
|
|
|
path := filepath.Join(dir, "d.sock")
|
|
listener, err := net.Listen("unix", path)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { assert.NoError(t, listener.Close()) })
|
|
|
|
gid := strconv.Itoa(os.Getgid())
|
|
require.Error(t, applySocketAccess(path, []string{"gid:" + gid}))
|
|
|
|
// Leaving it unrestricted is still allowed: that is the historical
|
|
// behaviour and grants nothing the mode did not already grant.
|
|
assert.NoError(t, applySocketAccess(path, nil))
|
|
})
|
|
}
|
|
|
|
// The kernel checks a Unix socket's mode at connect(), not at accept(), so a
|
|
// socket that is briefly wider than intended can be connected to before the
|
|
// daemon narrows it, and that caller stays connected afterwards. The bind must
|
|
// therefore land on the final mode, whatever umask the service manager used.
|
|
func TestListenUnixPrivate_BindsAtTheFinalMode(t *testing.T) {
|
|
// A umask the daemon might have inherited from its service manager. Nonzero
|
|
// and not one of the masks under test, so it proves both that the bind mode
|
|
// does not depend on it and that it is put back afterwards.
|
|
const callerUmask = 0o027
|
|
|
|
tests := []struct {
|
|
name string
|
|
allowed []string
|
|
want os.FileMode
|
|
}{
|
|
{name: "unrestricted binds open, so nothing has to widen it later", want: 0666},
|
|
{name: "restricted binds owner-only, for applySocketAccess to hand to the group",
|
|
allowed: []string{"gid:0"}, want: 0600},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
previous := syscall.Umask(callerUmask)
|
|
t.Cleanup(func() { syscall.Umask(previous) })
|
|
|
|
dir, err := os.MkdirTemp("", "nb-sock")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { assert.NoError(t, os.RemoveAll(dir)) })
|
|
|
|
path := filepath.Join(dir, "d.sock")
|
|
listener, err := listenUnixPrivate(path, tc.allowed)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { assert.NoError(t, listener.Close()) })
|
|
|
|
// Immediately after the bind, so nothing else can have moved it.
|
|
restored := syscall.Umask(callerUmask)
|
|
assert.Equal(t, callerUmask, restored, "listenUnixPrivate must restore the umask it changed")
|
|
|
|
assert.Equal(t, tc.want, socketMode(t, path))
|
|
})
|
|
}
|
|
}
|
|
|
|
func listenTestSocket(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
// Short, because the sun_path of a Unix socket is about 100 bytes and
|
|
// t.TempDir() embeds the test name.
|
|
dir, err := os.MkdirTemp("", "nb-sock")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { assert.NoError(t, os.RemoveAll(dir)) })
|
|
|
|
path := filepath.Join(dir, "d.sock")
|
|
listener, err := net.Listen("unix", path)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { assert.NoError(t, listener.Close()) })
|
|
|
|
return path
|
|
}
|
|
|
|
func socketMode(t *testing.T, path string) os.FileMode {
|
|
t.Helper()
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
return info.Mode().Perm()
|
|
}
|
|
|
|
func socketGID(t *testing.T, path string) uint32 {
|
|
t.Helper()
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
|
require.True(t, ok, "stat of %s is not a syscall.Stat_t", path)
|
|
return stat.Gid
|
|
}
|