mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-07 07:21:27 +02:00
Authorize daemon IPC callers by their local identity
This commit is contained in:
15
client/internal/daemonaddr/owner.go
Normal file
15
client/internal/daemonaddr/owner.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package daemonaddr
|
||||
|
||||
// DaemonRunsAsSelf reports whether the daemon listening at addr runs as this very
|
||||
// user. That is what makes an unprivileged daemon authorize this process for the
|
||||
// changes it otherwise restricts to root or an administrator, so a client can tell
|
||||
// up front whether those controls are usable instead of letting a save fail.
|
||||
//
|
||||
// It is answered from the ownership of the socket or pipe the daemon created, so it
|
||||
// costs no round trip and needs no cooperation from the daemon. Ownership that
|
||||
// cannot be read is reported as false, including for a TCP address, so a caller
|
||||
// reading this as "the daemon would allow it" fails closed. The daemon remains the
|
||||
// only thing that authorizes anything: this only decides what a client offers.
|
||||
func DaemonRunsAsSelf(addr string) bool {
|
||||
return daemonRunsAsSelf(addr)
|
||||
}
|
||||
40
client/internal/daemonaddr/owner_unix.go
Normal file
40
client/internal/daemonaddr/owner_unix.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// daemonRunsAsSelf compares the owner of the daemon's Unix socket with this
|
||||
// process's uid. Root is not treated specially here: a root caller is privileged
|
||||
// on its own merits, and a root-owned socket says nothing about the caller.
|
||||
func daemonRunsAsSelf(addr string) bool {
|
||||
path, ok := strings.CutPrefix(addr, "unix://")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Debugf("stat daemon socket %s: %v", path, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Only a socket says anything about a daemon. A directory or a leftover
|
||||
// regular file at that path is not one, and reading it as "the daemon runs as
|
||||
// us" would offer controls the daemon then refuses.
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return stat.Uid == uint32(os.Getuid())
|
||||
}
|
||||
62
client/internal/daemonaddr/owner_unix_test.go
Normal file
62
client/internal/daemonaddr/owner_unix_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A socket this user created means the daemon runs as this user, which is the
|
||||
// rootless case where the daemon delegates its authority to its own identity.
|
||||
func TestDaemonRunsAsSelf_OwnSocket(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "netbird.sock")
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := ln.Close(); err != nil {
|
||||
t.Logf("close listener: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if !DaemonRunsAsSelf("unix://" + path) {
|
||||
t.Error("a socket owned by this user must count as the daemon running as us")
|
||||
}
|
||||
}
|
||||
|
||||
// Everything that is not a readable socket of ours has to answer false, because
|
||||
// the caller reads a true as "the daemon would authorize me".
|
||||
func TestDaemonRunsAsSelf_FailsClosed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// A socket owned by another user, which is what a root-run daemon looks like
|
||||
// to an unprivileged client. Only assertable when we are not root ourselves.
|
||||
rootOwned := "unix:///var/run/netbird.sock"
|
||||
if _, err := os.Stat("/var/run/netbird.sock"); err == nil && os.Getuid() != 0 {
|
||||
if DaemonRunsAsSelf(rootOwned) {
|
||||
t.Error("a socket owned by another user must not count as ours")
|
||||
}
|
||||
}
|
||||
|
||||
for name, addr := range map[string]string{
|
||||
"missing socket": "unix://" + filepath.Join(dir, "absent.sock"),
|
||||
"tcp address": "tcp://127.0.0.1:41731",
|
||||
"named pipe": "npipe://netbird",
|
||||
"empty": "",
|
||||
"no scheme": filepath.Join(dir, "absent.sock"),
|
||||
"directory": "unix://" + dir,
|
||||
"unknown scheme": "http://localhost:8080",
|
||||
"scheme only": "unix://",
|
||||
"relative socket": "unix://netbird.sock",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if DaemonRunsAsSelf(addr) {
|
||||
t.Errorf("%q must not count as a daemon running as us", addr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
42
client/internal/daemonaddr/owner_windows.go
Normal file
42
client/internal/daemonaddr/owner_windows.go
Normal file
@@ -0,0 +1,42 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// daemonRunsAsSelf reads the owner of the daemon's pipe. A daemon running as the
|
||||
// service account owns its pipe as LocalSystem, and an elevated one as
|
||||
// BUILTIN\Administrators, so only a daemon the user started themselves matches.
|
||||
func daemonRunsAsSelf(addr string) bool {
|
||||
name, ok := strings.CutPrefix(addr, pipeScheme)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, path := range PipePaths(name) {
|
||||
// Bounded: this runs on the UI's path for deciding which controls to
|
||||
// offer, so a pipe that does not answer promptly must not stall it. A
|
||||
// timeout leaves the caller unprivileged, which only disables controls.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
|
||||
conn, err := dialPipe(ctx, path)
|
||||
cancel()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
owned := ipcauth.PipeOwnedBySelf(conn)
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("close daemon pipe %s after ownership check: %v", path, cerr)
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
103
client/internal/daemonaddr/pipe.go
Normal file
103
client/internal/daemonaddr/pipe.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
// WindowsPipeAddr is the default daemon address on Windows. A named pipe
|
||||
// carries the connecting process's token, which loopback TCP does not, so
|
||||
// it is the only Windows transport on which the daemon can tell who is
|
||||
// calling it.
|
||||
WindowsPipeAddr = "npipe://netbird"
|
||||
|
||||
// legacyWindowsAddr is the loopback-TCP address the Windows daemon used
|
||||
// before named-pipe support.
|
||||
legacyWindowsAddr = "tcp://127.0.0.1:41731"
|
||||
|
||||
pipeScheme = "npipe://"
|
||||
|
||||
// protectedPrefix is the NPFS namespace in which only LocalSystem and
|
||||
// members of BUILTIN\Administrators may create a pipe. A daemon running as
|
||||
// the service account creates its pipe there so that an unprivileged process
|
||||
// cannot pre-create the name, which would keep the daemon from starting and
|
||||
// leave callers talking to the squatter. Opening such a pipe needs no
|
||||
// privilege, so unprivileged clients still reach the daemon.
|
||||
protectedPrefix = `ProtectedPrefix\Administrators\`
|
||||
)
|
||||
|
||||
// DialTarget returns the gRPC dial target and transport options for a daemon
|
||||
// address. The npipe scheme needs a context dialer because gRPC has no
|
||||
// named-pipe resolver; unix and tcp are handled by gRPC itself.
|
||||
func DialTarget(addr string) (string, []grpc.DialOption) {
|
||||
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
|
||||
if name, ok := strings.CutPrefix(addr, pipeScheme); ok {
|
||||
paths := PipePaths(name)
|
||||
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return dialPipePaths(ctx, paths)
|
||||
}))
|
||||
return "passthrough:///netbird-daemon-pipe", opts
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(addr, "tcp://"), opts
|
||||
}
|
||||
|
||||
// PipePath maps an npipe address name ("netbird", from "npipe://netbird") to a
|
||||
// Windows named-pipe path (\\.\pipe\netbird). A fully qualified path is left as
|
||||
// is.
|
||||
func PipePath(name string) string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return name
|
||||
}
|
||||
return `\\.\pipe\` + name
|
||||
}
|
||||
|
||||
// PipePaths returns the paths a daemon control pipe may live at for an npipe
|
||||
// address name, in the order both sides must try them: the protected name first,
|
||||
// then the plain one.
|
||||
//
|
||||
// The daemon serves the first it can create, which is the protected name when it
|
||||
// runs as the service account and the plain one when it runs as an ordinary user,
|
||||
// as it does in netstack mode. Clients therefore have to try both, and because a
|
||||
// client cannot tell from the name alone who created the pipe, the plain name is
|
||||
// only usable once the server's identity has been checked: see
|
||||
// verifyPipeServer.
|
||||
//
|
||||
// A fully qualified path is what the operator asked for and is used as is.
|
||||
func PipePaths(name string) []string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return []string{name}
|
||||
}
|
||||
return []string{PipePath(protectedPrefix + name), PipePath(name)}
|
||||
}
|
||||
|
||||
// IsProtectedPipePath reports whether a pipe path is in the namespace only an
|
||||
// administrator or LocalSystem can create in, which is what lets a client trust
|
||||
// such a pipe from its name alone.
|
||||
func IsProtectedPipePath(path string) bool {
|
||||
return strings.HasPrefix(path, `\\.\pipe\`+protectedPrefix)
|
||||
}
|
||||
|
||||
// MigrateLegacy upgrades the pre-named-pipe Windows daemon address to the named
|
||||
// pipe, reporting whether it rewrote the address. Existing installs persist the
|
||||
// daemon address, so without this an upgraded daemon would keep listening on
|
||||
// loopback TCP, where callers carry no identity and privileged operations would
|
||||
// have to be refused for everyone. Only the exact legacy default is rewritten:
|
||||
// a deliberately chosen custom address is left alone.
|
||||
func MigrateLegacy(addr string) (string, bool) {
|
||||
return migrateLegacyForOS(runtime.GOOS, addr)
|
||||
}
|
||||
|
||||
func migrateLegacyForOS(goos, addr string) (string, bool) {
|
||||
if goos == "windows" && addr == legacyWindowsAddr {
|
||||
return WindowsPipeAddr, true
|
||||
}
|
||||
return addr, false
|
||||
}
|
||||
15
client/internal/daemonaddr/pipe_other.go
Normal file
15
client/internal/daemonaddr/pipe_other.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// dialPipePaths is Windows-only: no other platform serves the daemon on a named
|
||||
// pipe.
|
||||
func dialPipePaths(context.Context, []string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("named pipes are only supported on Windows")
|
||||
}
|
||||
30
client/internal/daemonaddr/pipe_test.go
Normal file
30
client/internal/daemonaddr/pipe_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The protected name must be tried before the plain one on both sides: it is the
|
||||
// one an unprivileged process cannot create, so preferring it is what keeps a
|
||||
// squatter from owning the name the service daemon would otherwise use.
|
||||
func TestPipePaths_PrefersTheProtectedName(t *testing.T) {
|
||||
got := PipePaths("netbird")
|
||||
want := []string{
|
||||
`\\.\pipe\ProtectedPrefix\Administrators\netbird`,
|
||||
`\\.\pipe\netbird`,
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("PipePaths = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator who passes a full path chose exactly one pipe, so neither side may
|
||||
// look anywhere else.
|
||||
func TestPipePaths_QualifiedPathIsUsedAsIs(t *testing.T) {
|
||||
path := `\\.\pipe\custom-netbird`
|
||||
got := PipePaths(path)
|
||||
if !slices.Equal(got, []string{path}) {
|
||||
t.Errorf("PipePaths = %q, want just %q", got, path)
|
||||
}
|
||||
}
|
||||
59
client/internal/daemonaddr/pipe_windows.go
Normal file
59
client/internal/daemonaddr/pipe_windows.go
Normal file
@@ -0,0 +1,59 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// dialPipePaths connects to the first path that answers with a pipe server this
|
||||
// client may trust, and returns the last error when none does.
|
||||
func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) {
|
||||
var lastErr error
|
||||
for _, path := range paths {
|
||||
conn, err := dialPipe(ctx, path)
|
||||
if err != nil {
|
||||
log.Debugf("dial daemon pipe %s: %v", path, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// A pipe in the protected namespace could only have been created by an
|
||||
// administrator or LocalSystem, so its name is the guarantee. Any other
|
||||
// name has to be checked, because any local user can create one.
|
||||
if !IsProtectedPipePath(path) {
|
||||
if err := ipcauth.PipeServerTrusted(conn); err != nil {
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
log.Debugf("close untrusted pipe %s: %v", path, closeErr)
|
||||
}
|
||||
lastErr = fmt.Errorf("%s: %w", path, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no daemon pipe to connect to")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// dialPipe connects to the daemon control pipe at SECURITY_IDENTIFICATION.
|
||||
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon
|
||||
// cannot read the caller's token at all. Identification lets the daemon read the
|
||||
// caller's SID and groups without granting it the ability to act as the caller.
|
||||
func dialPipe(ctx context.Context, path string) (net.Conn, error) {
|
||||
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
|
||||
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
|
||||
}
|
||||
9
client/internal/daemonaddr/resolve_pipe_other.go
Normal file
9
client/internal/daemonaddr/resolve_pipe_other.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
// ResolveDaemonAddr is a no-op off Windows, where there is no named-pipe
|
||||
// default to fall back from.
|
||||
func ResolveDaemonAddr(addr string) string {
|
||||
return addr
|
||||
}
|
||||
82
client/internal/daemonaddr/resolve_pipe_windows.go
Normal file
82
client/internal/daemonaddr/resolve_pipe_windows.go
Normal file
@@ -0,0 +1,82 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// probeTimeout bounds each transport probe. Both are local, so a daemon that is
|
||||
// listening answers immediately and one that is not fails immediately.
|
||||
const probeTimeout = 300 * time.Millisecond
|
||||
|
||||
// ResolveDaemonAddr keeps a client on the named pipe and never silently moves it
|
||||
// off. When the pipe does not answer it checks the legacy loopback TCP address, so
|
||||
// a client meeting a daemon that has not restarted since the upgrade can say what
|
||||
// is wrong, but it does not connect there.
|
||||
//
|
||||
// Using that address automatically would be a downgrade the user never asked for:
|
||||
// any local process can bind 127.0.0.1 while the daemon is not listening, and the
|
||||
// transport carries no caller identity, so a client that accepted whatever answered
|
||||
// would hand a setup key, a pre-shared key or an SSO prompt to a local impostor. An
|
||||
// operator who needs the legacy address during the upgrade window can still pass
|
||||
// --daemon-addr explicitly, which is a deliberate choice and still refuses the
|
||||
// privileged operations.
|
||||
//
|
||||
// Only the pipe address is resolved. A custom address is left alone, though passing
|
||||
// --daemon-addr npipe://netbird explicitly is indistinguishable from the default
|
||||
// here, so it is treated the same way.
|
||||
func ResolveDaemonAddr(addr string) string {
|
||||
if addr != WindowsPipeAddr {
|
||||
return addr
|
||||
}
|
||||
|
||||
for _, path := range PipePaths("netbird") {
|
||||
if pipeAvailable(path) {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
|
||||
if tcpAvailable(legacyWindowsAddr) {
|
||||
log.Warnf("the daemon is not serving %s, but something is listening on the legacy %s. "+
|
||||
"Restart the NetBird service so it serves the pipe. That address is not used automatically: "+
|
||||
"any local user can bind it and it carries no caller identity, so pass --daemon-addr %s "+
|
||||
"explicitly if you accept that",
|
||||
WindowsPipeAddr, legacyWindowsAddr, legacyWindowsAddr)
|
||||
}
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
func pipeAvailable(path string) bool {
|
||||
timeout := probeTimeout
|
||||
conn, err := winio.DialPipe(path, &timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("close daemon pipe probe: %v", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func tcpAvailable(addr string) bool {
|
||||
host := addr
|
||||
if _, after, ok := strings.Cut(addr, "://"); ok {
|
||||
host = after
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", host, probeTimeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("close daemon TCP probe: %v", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user