WIP: acl interceptor and named pipe ui

This commit is contained in:
Theodor S. Midtlien
2026-07-23 14:14:42 +02:00
parent a12a3e4603
commit 27afbd7952
58 changed files with 3163 additions and 475 deletions
+90
View File
@@ -0,0 +1,90 @@
package ipcauth
import (
"slices"
"strconv"
)
// Ownership is a profile's access policy: the typed owner principals plus the
// opt-in shared flag.
type Ownership struct {
Owners []string
Shared bool
}
// GroupResolver resolves a Unix caller's effective group IDs (primary +
// supplementary, NSS-aware) and owner group names to GIDs. It is only consulted
// for Unix `gid:`/`group:` owners; Windows uses the SIDs carried in the Identity.
// A nil resolver disables group matching.
type GroupResolver interface {
// CallerGIDs returns the set of group IDs the caller belongs to.
CallerGIDs(id Identity) map[uint32]struct{}
// GroupNameGID resolves a group name to its GID.
GroupNameGID(name string) (uint32, bool)
}
// Authorize reports whether the identity may control a profile with the given
// ownership. Privileged callers (root / elevated-admin / LocalSystem) and shared
// profiles are always allowed; otherwise the identity must match one of the
// owner principals.
func Authorize(o Ownership, id Identity, r GroupResolver) bool {
if id.IsPrivileged() {
return true
}
if o.Shared {
return true
}
for _, raw := range o.Owners {
p, ok := ParsePrincipal(raw)
if !ok {
continue
}
if principalMatches(p, id, r) {
return true
}
}
return false
}
func principalMatches(p Principal, id Identity, r GroupResolver) bool {
switch p.Kind {
case KindUID:
if id.IsWindows() {
return false
}
uid, err := strconv.ParseUint(p.Value, 10, 32)
return err == nil && uint32(uid) == id.UID
case KindGID:
if id.IsWindows() {
return false
}
gid, err := strconv.ParseUint(p.Value, 10, 32)
return err == nil && callerHasGID(uint32(gid), id, r)
case KindGroup:
if id.IsWindows() || r == nil {
return false
}
gid, ok := r.GroupNameGID(p.Value)
return ok && callerHasGID(gid, id, r)
case KindSID:
if !id.IsWindows() {
return false
}
return id.SID == p.Value || slices.Contains(id.Groups, p.Value)
default:
return false
}
}
// callerHasGID reports whether gid is the caller's primary GID (from peercred,
// no lookup) or one of their supplementary groups (NSS-resolved via r).
func callerHasGID(gid uint32, id Identity, r GroupResolver) bool {
if id.GID == gid {
return true
}
if r == nil {
return false
}
_, ok := r.CallerGIDs(id)[gid]
return ok
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux && !darwin && !freebsd && !windows
package ipcauth
import "google.golang.org/grpc/credentials"
// NewTransportCredentials returns nil on platforms without a peer-identity
// primitive. The daemon falls back to insecure credentials and skips per-RPC
// authorization (logging a warning), preserving pre-hardening behavior.
func NewTransportCredentials() credentials.TransportCredentials {
return nil
}
+48
View File
@@ -0,0 +1,48 @@
//go:build linux || darwin || freebsd
package ipcauth
import (
"context"
"net"
"google.golang.org/grpc/credentials"
)
// NewTransportCredentials returns gRPC transport credentials that extract the
// caller's kernel-authenticated identity from a Unix-socket connection and
// expose it via IdentityFromContext. Non-nil on platforms with a
// peer-credential primitive.
func NewTransportCredentials() credentials.TransportCredentials {
return unixCreds{}
}
// unixCreds implements credentials.TransportCredentials over a Unix socket.
// The server side reads SO_PEERCRED/LOCAL_PEERCRED during the handshake; the
// client side is a no-op (the kernel supplies the peer identity to the server
// without any client cooperation), so an ordinary insecure client still works.
type unixCreds struct{}
func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
return conn, AuthInfo{}, nil
}
// ServerHandshake extracts the peer identity and fails closed if it cannot be read.
func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
id, err := PeerIdentity(conn)
if err != nil {
return nil, nil, err
}
return conn, AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: id,
}, nil
}
func (unixCreds) Info() credentials.ProtocolInfo {
return credentials.ProtocolInfo{SecurityProtocol: "netbird-ipc-peercred"}
}
func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} }
func (unixCreds) OverrideServerName(string) error { return nil }
+143
View File
@@ -0,0 +1,143 @@
//go:build windows
package ipcauth
import (
"context"
"fmt"
"net"
"runtime"
"golang.org/x/sys/windows"
"google.golang.org/grpc/credentials"
)
var (
modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
procRevertToSelf = modadvapi32.NewProc("RevertToSelf")
)
// Windows group-SID attribute flags (winnt.h): a group only counts toward
// membership when it is enabled and not marked use-for-deny-only.
const (
seGroupEnabled = 0x00000004
seGroupUseForDenyOnly = 0x00000010
)
// DefaultPipeSDDL restricts the daemon control pipe to LocalSystem (SY), the
// Administrators group (BA), and interactive logon users (IU). It deliberately
// excludes Authenticated Users / Everyone so remote or arbitrary service
// principals cannot connect. This is the connection gate; the interceptor still
// does per-RPC authorization by caller identity.
func DefaultPipeSDDL() string {
return "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;IU)"
}
// NewTransportCredentials returns gRPC transport credentials that derive the
// caller's identity from the named-pipe client token, following Microsoft's
// "Verifying Client Access with ACLs" pattern: ImpersonateNamedPipeClient ->
// OpenThreadToken -> RevertToSelf. Per threat-model M-NOIMP, impersonation is
// used only to read the client token for identity, never to perform privileged work.
func NewTransportCredentials() credentials.TransportCredentials {
return winpipeCreds{}
}
type winpipeCreds struct{}
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
return conn, AuthInfo{}, nil
}
// ServerHandshake extracts the connecting client's identity from the pipe token.
// Fails closed if the handle or token cannot be read.
func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
// go-winio's pipe connection embeds *win32File, which exposes Fd().
fdConn, ok := conn.(interface{ Fd() uintptr })
if !ok {
return nil, nil, fmt.Errorf("connection %T does not expose a pipe handle", conn)
}
handle := windows.Handle(fdConn.Fd())
id, err := pipeClientIdentity(handle)
if err != nil {
return nil, nil, err
}
return conn, AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: id,
}, nil
}
func (winpipeCreds) Info() credentials.ProtocolInfo {
return credentials.ProtocolInfo{SecurityProtocol: "netbird-ipc-peercred"}
}
func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} }
func (winpipeCreds) OverrideServerName(string) error { return nil }
// pipeClientIdentity reads the connecting client's user SID, enabled group SIDs,
// and elevation from the named-pipe handle. The impersonation window is kept as
// small as possible and pinned to the OS thread (impersonation is thread-local).
func pipeClientIdentity(handle windows.Handle) (Identity, error) {
var pid uint32
hasPID := windows.GetNamedPipeClientProcessId(handle, &pid) == nil
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := impersonateNamedPipeClient(handle); err != nil {
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
}
defer func() { _ = revertToSelf() }()
// openAsSelf=true: the token is opened using the daemon's process context
// (LocalSystem), not the impersonated client's, so the open always succeeds.
var token windows.Token
if err := windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil {
return Identity{}, fmt.Errorf("open thread token: %w", err)
}
defer token.Close()
tu, err := token.GetTokenUser()
if err != nil {
return Identity{}, fmt.Errorf("get token user: %w", err)
}
tg, err := token.GetTokenGroups()
if err != nil {
return Identity{}, fmt.Errorf("get token groups: %w", err)
}
var groups []string
for _, g := range tg.AllGroups() {
if g.Attributes&seGroupEnabled == 0 || g.Attributes&seGroupUseForDenyOnly != 0 {
continue
}
groups = append(groups, g.Sid.String())
}
return Identity{
SID: tu.User.Sid.String(),
Groups: groups,
Elevated: token.IsElevated(),
PID: int32(pid),
HasPID: hasPID,
}, nil
}
func impersonateNamedPipeClient(h windows.Handle) error {
r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h))
if r == 0 {
return e
}
return nil
}
func revertToSelf() error {
r, _, e := procRevertToSelf.Call()
if r == 0 {
return e
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package ipcauth
import (
"context"
"strconv"
"google.golang.org/grpc/metadata"
)
// Metadata keys used by the local JSON gateway to forward the HTTP client's
// identity to the daemon. Trusted by the interceptor ONLY when the gRPC peer is
// itself the daemon (self/privileged) — i.e. the loopback gateway — so a direct
// gRPC caller cannot forge them.
const (
mdFwdUID = "x-netbird-fwd-uid"
mdFwdGID = "x-netbird-fwd-gid"
mdFwdPID = "x-netbird-fwd-pid"
)
// ForwardIdentityMetadata encodes a Unix identity for the gateway to forward to
// the daemon. Windows identities are not forwarded (the gateway cannot read a
// pipe token for an HTTP client); nil is returned in that case.
func ForwardIdentityMetadata(id Identity) metadata.MD {
if id.IsWindows() {
return nil
}
md := metadata.Pairs(
mdFwdUID, strconv.FormatUint(uint64(id.UID), 10),
mdFwdGID, strconv.FormatUint(uint64(id.GID), 10),
)
if id.HasPID {
md.Set(mdFwdPID, strconv.FormatInt(int64(id.PID), 10))
}
return md
}
// forwardedIdentity extracts a forwarded Unix identity from incoming gRPC
// metadata, if present and well-formed.
func forwardedIdentity(ctx context.Context) (Identity, bool) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return Identity{}, false
}
uidStr := mdFirst(md, mdFwdUID)
if uidStr == "" {
return Identity{}, false
}
uid, err := strconv.ParseUint(uidStr, 10, 32)
if err != nil {
return Identity{}, false
}
id := Identity{UID: uint32(uid)}
if g := mdFirst(md, mdFwdGID); g != "" {
if v, err := strconv.ParseUint(g, 10, 32); err == nil {
id.GID = uint32(v)
}
}
if p := mdFirst(md, mdFwdPID); p != "" {
if v, err := strconv.ParseInt(p, 10, 32); err == nil {
id.PID = int32(v)
id.HasPID = true
}
}
return id, true
}
func mdFirst(md metadata.MD, key string) string {
if v := md.Get(key); len(v) > 0 {
return v[0]
}
return ""
}
+106
View File
@@ -0,0 +1,106 @@
// Package ipcauth provides the kernel-authenticated identity of a local IPC
// (gRPC) caller and the transport credentials that surface it into the gRPC
// context, so the daemon can authorize each RPC by caller identity.
//
// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or
// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the named-pipe
// client token. Platforms without a peer-identity primitive get no credentials
// and therefore no enforcement (the daemon logs a warning and stays open,
// preserving today's behavior until the transport gains an identity primitive).
package ipcauth
import (
"context"
"fmt"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
)
// sidLocalSystem is the well-known Windows SID for the LocalSystem account.
const sidLocalSystem = "S-1-5-18"
// Identity is the kernel-authenticated identity of a local IPC caller. The zero
// value is not a valid identity; obtain one via IdentityFromContext (which
// reports presence) or PeerIdentity.
type Identity struct {
// UID and GID are the caller's Unix user ID and primary group ID.
// Zero on Windows, where SID is authoritative instead.
UID uint32
GID uint32
// PID is the caller's process ID, for audit only. HasPID is false when the
// platform cannot supply it (e.g. Darwin/FreeBSD xucred carries no PID).
PID int32
HasPID bool
// SID is the caller's Windows security identifier (empty on Unix).
SID string
// Groups holds the caller's Windows group SIDs, captured from the client
// token at handshake (empty on Unix, where supplementary group membership is
// resolved on demand via NSS/getent by the authorizer).
Groups []string
// Elevated reports whether the Windows client token is elevated (run as
// administrator). Always false on Unix, where privilege is uid==0.
Elevated bool
}
// IsWindows reports whether this identity is a Windows principal (SID-based)
// rather than a Unix uid/gid principal.
func (i Identity) IsWindows() bool {
return i.SID != ""
}
// IsPrivileged reports whether the caller is the platform's administrative
// principal — Unix root (uid 0), or on Windows an elevated token or LocalSystem.
// It deliberately requires actual elevation on Windows (a non-elevated member of
// Administrators has a filtered token and is NOT privileged), mirroring "must
// really be root" on Unix.
func (i Identity) IsPrivileged() bool {
if i.IsWindows() {
return i.Elevated || i.SID == sidLocalSystem
}
return i.UID == 0
}
// String renders the identity for audit logs.
func (i Identity) String() string {
if i.IsWindows() {
if i.HasPID {
return fmt.Sprintf("sid=%s elevated=%t pid=%d", i.SID, i.Elevated, i.PID)
}
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
}
if i.HasPID {
return fmt.Sprintf("uid=%d gid=%d pid=%d", i.UID, i.GID, i.PID)
}
return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID)
}
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so the
// interceptor can retrieve it from the request context via IdentityFromContext.
type AuthInfo struct {
credentials.CommonAuthInfo
Identity Identity
}
// AuthType identifies the authentication scheme.
func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" }
// IdentityFromContext extracts the caller's kernel-authenticated identity from
// the gRPC peer context. The second return value is false when no IPC transport
// credentials were negotiated (e.g. an unsupported platform, or a caller that
// did not come through the daemon socket) — callers MUST fail closed in that case.
func IdentityFromContext(ctx context.Context) (Identity, bool) {
p, ok := peer.FromContext(ctx)
if !ok {
return Identity{}, false
}
info, ok := p.AuthInfo.(AuthInfo)
if !ok {
return Identity{}, false
}
return info.Identity, true
}
+50
View File
@@ -0,0 +1,50 @@
package ipcauth
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
)
func TestIdentityFromContext_NoPeer(t *testing.T) {
_, ok := IdentityFromContext(context.Background())
assert.False(t, ok, "bare context must report no identity (fail closed)")
}
func TestIdentityFromContext_WrongAuthInfo(t *testing.T) {
ctx := peer.NewContext(context.Background(), &peer.Peer{})
_, ok := IdentityFromContext(ctx)
assert.False(t, ok, "peer without our AuthInfo must report no identity")
}
func TestIdentityFromContext_Present(t *testing.T) {
want := Identity{UID: 1000, GID: 1000, PID: 4242, HasPID: true}
ctx := peer.NewContext(context.Background(), &peer.Peer{
AuthInfo: AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: want,
},
})
got, ok := IdentityFromContext(ctx)
assert.True(t, ok)
assert.Equal(t, want, got)
}
func TestIdentity_IsPrivileged(t *testing.T) {
// Unix
assert.True(t, Identity{UID: 0}.IsPrivileged(), "root is privileged")
assert.False(t, Identity{UID: 1000}.IsPrivileged(), "non-root is not privileged")
// Windows
assert.True(t, Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}.IsPrivileged(), "elevated admin is privileged")
assert.True(t, Identity{SID: "S-1-5-18"}.IsPrivileged(), "LocalSystem is privileged")
assert.False(t, Identity{SID: "S-1-5-21-1-2-3-1001"}.IsPrivileged(), "non-elevated admin is NOT privileged")
}
func TestIdentity_IsWindows(t *testing.T) {
assert.True(t, Identity{SID: "S-1-5-18"}.IsWindows())
assert.False(t, Identity{UID: 0}.IsWindows())
}
+124
View File
@@ -0,0 +1,124 @@
package ipcauth
import (
"context"
"os"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Interceptor enforces per-RPC authorization on the daemon control channel,
// keyed to the caller's kernel-authenticated identity. It is safe-by-default:
// any RPC without a matching bypass is gated by the active profile's ownership,
// and a caller without a readable identity is denied.
type Interceptor struct {
policy ProfilePolicy
resolver GroupResolver
// selfUID is the daemon's own effective UID. A caller whose UID matches it
// (rootless container / foreground daemon running as the invoking user) is
// allowed: it already has full control of the daemon process. -1 on Windows.
selfUID int
}
// NewInterceptor builds an interceptor over the given policy and group resolver.
func NewInterceptor(policy ProfilePolicy, resolver GroupResolver) *Interceptor {
return &Interceptor{policy: policy, resolver: resolver, selfUID: os.Geteuid()}
}
// UnaryServerInterceptor authorizes each unary RPC before the handler runs.
func (i *Interceptor) UnaryServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if err := i.authorize(ctx, info.FullMethod); err != nil {
return nil, err
}
return handler(ctx, req)
}
}
// StreamServerInterceptor authorizes each streaming RPC before the handler runs.
func (i *Interceptor) StreamServerInterceptor() grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if err := i.authorize(ss.Context(), info.FullMethod); err != nil {
return err
}
return handler(srv, ss)
}
}
func (i *Interceptor) authorize(ctx context.Context, fullMethod string) error {
id, ok := IdentityFromContext(ctx)
if !ok {
log.Warnf("ipc authz: DENY %s — caller identity unavailable", fullMethod)
return status.Error(codes.PermissionDenied, "caller identity could not be verified on the daemon control channel")
}
if i.isSelfOrPrivileged(id) {
// The local JSON gateway connects as the daemon itself (self/privileged)
// and forwards the real HTTP client's identity. Trust it here — and only
// here, where the transport peer is already the daemon — then authorize
// as the forwarded client. A direct non-privileged caller never reaches
// this branch, so it cannot forge the forwarding metadata.
fwd, hasFwd := forwardedIdentity(ctx)
if !hasFwd {
i.auditAllow(id, fullMethod)
return nil
}
log.Infof("ipc authz: honoring gateway-forwarded identity %s", fwd)
id = fwd
if i.isSelfOrPrivileged(id) {
i.auditAllow(id, fullMethod)
return nil
}
}
// Per-user / per-target-profile RPCs authorize themselves in the handler.
if handlerAuthorizedMethods[fullMethod] {
return nil
}
o := i.policy.ActiveProfileOwnership()
// Trust-on-first-use: an unowned, non-shared profile is claimed by the first
// caller. The claim is atomic; if we lose the race we re-read and authorize.
if len(o.Owners) == 0 && !o.Shared {
claimed, err := i.policy.ClaimActiveProfileOwnerIfUnowned(id)
if err != nil {
log.Errorf("ipc authz: claim active profile for %s: %v", id, err)
return status.Error(codes.Internal, "failed to claim profile ownership")
}
if claimed {
log.Infof("ipc authz: %s claimed ownership of the active profile (trust-on-first-use)", id)
i.auditAllow(id, fullMethod)
return nil
}
o = i.policy.ActiveProfileOwnership()
}
if Authorize(o, id, i.resolver) {
i.auditAllow(id, fullMethod)
return nil
}
log.Warnf("ipc authz: DENY %s for %s — active profile owned by another principal", fullMethod, id)
return status.Errorf(codes.PermissionDenied,
"not authorized to control the active profile (caller %s); ask an owner or run as root/administrator", id)
}
// isSelfOrPrivileged reports whether the caller is the platform administrator
// (root / elevated-admin / LocalSystem) or the daemon's own user.
func (i *Interceptor) isSelfOrPrivileged(id Identity) bool {
if id.IsPrivileged() {
return true
}
// Daemon-self: only meaningful on Unix (Windows privilege is covered above).
return !id.IsWindows() && i.selfUID >= 0 && int(id.UID) == i.selfUID
}
func (i *Interceptor) auditAllow(id Identity, fullMethod string) {
if auditMethods[fullMethod] {
log.Infof("ipc authz: allow %s for %s", fullMethod, id)
}
}
+139
View File
@@ -0,0 +1,139 @@
package ipcauth
import (
"context"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
type mockPolicy struct {
o Ownership
claimed bool
}
func (m *mockPolicy) ActiveProfileOwnership() Ownership { return m.o }
// ClaimActiveProfileOwnerIfUnowned records a claim and marks the profile owned.
func (m *mockPolicy) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
if len(m.o.Owners) == 0 && !m.o.Shared {
m.o.Owners = []string{OwnerPrincipalForIdentity(id)}
m.claimed = true
return true, nil
}
return false, nil
}
type mockResolver struct {
gids map[uint32]struct{}
names map[string]uint32
}
func (m mockResolver) CallerGIDs(Identity) map[uint32]struct{} { return m.gids }
func (m mockResolver) GroupNameGID(n string) (uint32, bool) { g, ok := m.names[n]; return g, ok }
func ctxWith(id Identity) context.Context {
return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: AuthInfo{Identity: id}})
}
const (
up = servicePath + "Up"
list = servicePath + "ListProfiles"
unkwn = servicePath + "SomeFutureMethod"
)
func TestInterceptorAuthorize(t *testing.T) {
const selfUID = 4000
tests := []struct {
name string
own Ownership
resolver GroupResolver
ctx context.Context
method string
wantErr bool
}{
{"no identity denies", Ownership{}, nil, context.Background(), up, true},
{"root allowed", Ownership{}, nil, ctxWith(Identity{UID: 0}), up, false},
{"daemon-self allowed", Ownership{}, nil, ctxWith(Identity{UID: selfUID}), up, false},
{"shared allows any", Ownership{Shared: true}, nil, ctxWith(Identity{UID: 1234}), up, false},
{"uid owner allowed", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), up, false},
{"non-owner denied", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), up, true},
{"handler-authorized bypass", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), list, false},
{"unknown method gated", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), unkwn, true},
{"primary gid owner", Ownership{Owners: []string{"gid:5000"}}, nil, ctxWith(Identity{UID: 2000, GID: 5000}), up, false},
{"group-name owner via resolver", Ownership{Owners: []string{"group:admins"}},
mockResolver{names: map[string]uint32{"admins": 5000}, gids: map[uint32]struct{}{5000: {}}},
ctxWith(Identity{UID: 2000, GID: 42}), up, false},
{"windows sid owner", Ownership{Owners: []string{"sid:S-1-5-21-9"}}, nil,
ctxWith(Identity{SID: "S-1-5-21-9"}), up, false},
{"windows group-sid owner", Ownership{Owners: []string{"sid:S-1-5-32-544"}}, nil,
ctxWith(Identity{SID: "S-1-5-21-1", Groups: []string{"S-1-5-32-544"}}), up, false},
{"windows elevated privileged", Ownership{}, nil,
ctxWith(Identity{SID: "S-1-5-21-1", Elevated: true}), up, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &Interceptor{policy: &mockPolicy{o: tt.own}, resolver: tt.resolver, selfUID: selfUID}
err := i.authorize(tt.ctx, tt.method)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, codes.PermissionDenied, status.Code(err))
} else {
assert.NoError(t, err)
}
})
}
}
// TestInterceptorForwardedIdentity verifies the JSON-gateway trust model: a
// self/privileged transport peer (the loopback gateway) may forward a real
// client identity, but a non-privileged caller cannot forge it.
func TestInterceptorForwardedIdentity(t *testing.T) {
const selfUID = 4000
owners := Ownership{Owners: []string{"uid:1000"}}
withFwd := func(peerUID, fwdUID uint32) context.Context {
ctx := ctxWith(Identity{UID: peerUID})
return metadata.NewIncomingContext(ctx, metadata.Pairs(mdFwdUID, itoa(fwdUID)))
}
// Gateway (peer == daemon-self) forwards a non-owner client → denied as that client.
i := &Interceptor{policy: &mockPolicy{o: owners}, selfUID: selfUID}
assert.Error(t, i.authorize(withFwd(selfUID, 2000), up))
// Gateway forwards the owner → allowed.
assert.NoError(t, i.authorize(withFwd(selfUID, 1000), up))
// A non-privileged direct caller's forwarded metadata is IGNORED (can't forge):
// caller uid 2000 forwarding uid:1000 is still treated as 2000 → denied.
assert.Error(t, i.authorize(withFwd(2000, 1000), up))
}
func itoa(u uint32) string {
return strconv.FormatUint(uint64(u), 10)
}
// TestInterceptorTOFU verifies an unowned, non-shared profile is claimed by the
// first non-privileged caller, and a different caller is then denied.
func TestInterceptorTOFU(t *testing.T) {
policy := &mockPolicy{o: Ownership{}} // unowned
i := &Interceptor{policy: policy, resolver: nil, selfUID: 4000}
// First caller (uid 1000) claims via TOFU.
err := i.authorize(ctxWith(Identity{UID: 1000}), up)
assert.NoError(t, err)
assert.True(t, policy.claimed, "first caller should claim ownership")
assert.Equal(t, []string{"uid:1000"}, policy.o.Owners)
// A different caller is now denied (profile owned by uid 1000).
err = i.authorize(ctxWith(Identity{UID: 2000}), up)
assert.Error(t, err)
assert.Equal(t, codes.PermissionDenied, status.Code(err))
}
+43
View File
@@ -0,0 +1,43 @@
//go:build darwin || freebsd
package ipcauth
import (
"fmt"
"net"
"golang.org/x/sys/unix"
)
// PeerIdentity reads the kernel-authenticated identity of the process on the
// other end of a Unix socket connection via LOCAL_PEERCRED (xucred). xucred
// carries the uid and group list but no pid, so audit on these platforms is
// uid/gid-based (HasPID is false); PID via LOCAL_PEERPID is a possible follow-up.
func PeerIdentity(c net.Conn) (Identity, error) {
uc, ok := c.(*net.UnixConn)
if !ok {
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
}
raw, err := uc.SyscallConn()
if err != nil {
return Identity{}, fmt.Errorf("raw conn: %w", err)
}
var cred *unix.Xucred
var credErr error
if err := raw.Control(func(fd uintptr) {
cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
}); err != nil {
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
}
if credErr != nil {
return Identity{}, fmt.Errorf("LOCAL_PEERCRED: %w", credErr)
}
id := Identity{UID: cred.Uid}
// Groups[0] is the effective (primary) GID; guard against an empty list.
if cred.Ngroups > 0 {
id.GID = cred.Groups[0]
}
return id, nil
}
+43
View File
@@ -0,0 +1,43 @@
//go:build linux
package ipcauth
import (
"fmt"
"net"
"golang.org/x/sys/unix"
)
// PeerIdentity reads the kernel-authenticated identity of the process on the
// other end of a Unix socket connection via SO_PEERCRED. The credentials are
// captured by the kernel at connect() time and cannot be spoofed or changed for
// the life of the connection.
func PeerIdentity(c net.Conn) (Identity, error) {
uc, ok := c.(*net.UnixConn)
if !ok {
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
}
raw, err := uc.SyscallConn()
if err != nil {
return Identity{}, fmt.Errorf("raw conn: %w", err)
}
var cred *unix.Ucred
var credErr error
if err := raw.Control(func(fd uintptr) {
cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
}); err != nil {
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
}
if credErr != nil {
return Identity{}, fmt.Errorf("SO_PEERCRED: %w", credErr)
}
return Identity{
UID: cred.Uid,
GID: cred.Gid,
PID: cred.Pid,
HasPID: true,
}, nil
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !linux && !darwin && !freebsd
package ipcauth
import (
"fmt"
"net"
"runtime"
)
// PeerIdentity is unimplemented on platforms without a Unix-socket peer-credential
// primitive. Windows derives identity from the named-pipe client token instead
// (see the Windows transport credentials), so it never calls this.
func PeerIdentity(net.Conn) (Identity, error) {
return Identity{}, fmt.Errorf("peer credential check not supported on %s", runtime.GOOS)
}
@@ -0,0 +1,114 @@
//go:build linux || darwin || freebsd
package ipcauth
import (
"context"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
)
// TestPeerIdentity_MatchesCurrentProcess connects to a real Unix socket and
// verifies the extracted UID/GID match the running process (both ends are us).
func TestPeerIdentity_MatchesCurrentProcess(t *testing.T) {
sock := filepath.Join(t.TempDir(), "peer.sock")
ln, err := net.Listen("unix", sock)
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
type result struct {
id Identity
err error
}
done := make(chan result, 1)
go func() {
c, aerr := ln.Accept()
if aerr != nil {
done <- result{err: aerr}
return
}
defer func() { _ = c.Close() }()
id, ierr := PeerIdentity(c)
done <- result{id: id, err: ierr}
}()
client, err := net.Dial("unix", sock)
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
res := <-done
require.NoError(t, res.err)
assert.Equal(t, uint32(os.Getuid()), res.id.UID, "UID should match current process")
assert.Equal(t, uint32(os.Getgid()), res.id.GID, "primary GID should match current process")
}
// TestPeerIdentity_NonUnixConn rejects non-Unix connections (fail closed).
func TestPeerIdentity_NonUnixConn(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
done := make(chan error, 1)
go func() {
c, aerr := ln.Accept()
if aerr != nil {
done <- aerr
return
}
defer func() { _ = c.Close() }()
_, ierr := PeerIdentity(c)
done <- ierr
}()
client, err := net.Dial("tcp", ln.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
assert.Error(t, <-done, "PeerIdentity must reject a non-Unix connection")
}
// TestGRPCRoundTrip_ServerCredsClientInsecure proves the transport contract end
// to end: a gRPC server using the peercred transport credentials still serves a
// plain insecure client (the CLI never changed), and the caller's kernel
// identity reaches the handler via IdentityFromContext.
func TestGRPCRoundTrip_ServerCredsClientInsecure(t *testing.T) {
sock := filepath.Join(t.TempDir(), "rt.sock")
ln, err := net.Listen("unix", sock)
require.NoError(t, err)
var gotUID uint32
var gotOK bool
srv := grpc.NewServer(
grpc.Creds(NewTransportCredentials()),
grpc.UnaryInterceptor(func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, h grpc.UnaryHandler) (any, error) {
id, ok := IdentityFromContext(ctx)
gotUID, gotOK = id.UID, ok
return h(ctx, req)
}),
)
healthpb.RegisterHealthServer(srv, health.NewServer())
go func() { _ = srv.Serve(ln) }()
t.Cleanup(srv.Stop)
conn, err := grpc.NewClient("unix://"+sock, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{})
require.NoError(t, err, "insecure client must reach the peercred server")
assert.True(t, gotOK, "handler must see a peer identity")
assert.Equal(t, uint32(os.Getuid()), gotUID, "handler must see the caller's UID")
}
+92
View File
@@ -0,0 +1,92 @@
package ipcauth
import "sync"
const servicePath = "/daemon.DaemonService/"
// ProfilePolicy exposes the active profile's ownership to the interceptor. The
// daemon server implements it; ConfigAdapter bridges the gap because the gRPC
// server (and its interceptor) is constructed before the server instance exists.
type ProfilePolicy interface {
// ActiveProfileOwnership returns the active profile's ownership policy.
ActiveProfileOwnership() Ownership
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for
// id when it has no owners and is not shared (trust-on-first-use), and
// reports whether id is now an owner. A false return means the profile was
// already owned/shared or another caller won the claim — the caller must
// re-read ownership and authorize normally.
ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error)
}
// handlerAuthorizedMethods bypass the active-profile gate: they are per-user or
// per-target-profile operations whose handler does its own authorization (bound
// to the caller identity). Peer identity is still required to reach them.
var handlerAuthorizedMethods = map[string]bool{
servicePath + "AddProfile": true,
servicePath + "ListProfiles": true,
servicePath + "GetActiveProfile": true,
servicePath + "RemoveProfile": true,
servicePath + "RenameProfile": true,
}
// auditMethods are the Tier-C/H RPCs (threat model §3) whose successful
// authorization is worth an audit log line. Denials are always logged.
var auditMethods = map[string]bool{
servicePath + "GetConfig": true,
servicePath + "SetConfig": true,
servicePath + "Login": true,
servicePath + "WaitSSOLogin": true,
servicePath + "RequestJWTAuth": true,
servicePath + "WaitJWTToken": true,
servicePath + "StartCapture": true,
servicePath + "StartBundleCapture": true,
servicePath + "DebugBundle": true,
servicePath + "ExposeService": true,
servicePath + "Up": true,
servicePath + "Down": true,
servicePath + "SelectNetworks": true,
servicePath + "DeselectNetworks": true,
servicePath + "SwitchProfile": true,
servicePath + "TriggerUpdate": true,
servicePath + "Logout": true,
servicePath + "CleanState": true,
servicePath + "DeleteState": true,
}
// ConfigAdapter is a ProfilePolicy whose backend is set lazily, once the daemon
// server instance is created. Until then it reports an unowned profile
// (Ownership zero value), so non-privileged callers are denied — fail closed.
type ConfigAdapter struct {
mu sync.RWMutex
backend ProfilePolicy
}
// SetBackend installs the real policy. Must be called before serving RPCs.
func (a *ConfigAdapter) SetBackend(backend ProfilePolicy) {
a.mu.Lock()
defer a.mu.Unlock()
a.backend = backend
}
// ActiveProfileOwnership delegates to the backend, or reports an unowned profile
// when no backend is set yet.
func (a *ConfigAdapter) ActiveProfileOwnership() Ownership {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return Ownership{}
}
return a.backend.ActiveProfileOwnership()
}
// ClaimActiveProfileOwnerIfUnowned delegates to the backend. Before the backend
// is set it cannot claim, so it reports not-owned (fail closed).
func (a *ConfigAdapter) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return false, nil
}
return a.backend.ClaimActiveProfileOwnerIfUnowned(id)
}
+55
View File
@@ -0,0 +1,55 @@
package ipcauth
import (
"strconv"
"strings"
)
// PrincipalKind is the type of an owner principal.
type PrincipalKind string
const (
KindUID PrincipalKind = "uid" // Unix user ID
KindGID PrincipalKind = "gid" // Unix group ID
KindGroup PrincipalKind = "group" // Unix group name (NSS-resolved)
KindSID PrincipalKind = "sid" // Windows user or group SID
)
// Principal is a parsed owner entry from a profile's Owners list.
type Principal struct {
Kind PrincipalKind
Value string
}
// ParsePrincipal parses a "kind:value" owner string. Returns false for empty
// values or unknown kinds so malformed entries are ignored rather than trusted.
func ParsePrincipal(s string) (Principal, bool) {
kind, value, ok := strings.Cut(s, ":")
if !ok || value == "" {
return Principal{}, false
}
switch PrincipalKind(kind) {
case KindUID, KindGID, KindGroup, KindSID:
return Principal{Kind: PrincipalKind(kind), Value: value}, true
default:
return Principal{}, false
}
}
// UIDPrincipal builds the owner string for a Unix user ID.
func UIDPrincipal(uid uint32) string {
return string(KindUID) + ":" + strconv.FormatUint(uint64(uid), 10)
}
// SIDPrincipal builds the owner string for a Windows SID.
func SIDPrincipal(sid string) string { return string(KindSID) + ":" + sid }
// OwnerPrincipalForIdentity returns the self-ownership principal for an identity:
// the user's UID on Unix, or the user's SID on Windows. Used to auto-isolate a
// new profile to its creator.
func OwnerPrincipalForIdentity(id Identity) string {
if id.IsWindows() {
return SIDPrincipal(id.SID)
}
return UIDPrincipal(id.UID)
}
+72
View File
@@ -0,0 +1,72 @@
//go:build !windows
package ipcauth
import (
"strconv"
"sync"
"time"
"github.com/netbirdio/netbird/client/internal/shell"
)
const groupCacheTTL = 30 * time.Second
// NewDefaultGroupResolver returns an NSS-aware group resolver backed by
// getent/`id -G` (via client/internal/shell), so `gid:`/`group:` owners resolve
// correctly for LDAP/AD users under CGO_ENABLED=0. Results are cached briefly.
func NewDefaultGroupResolver() GroupResolver {
return &nssResolver{byUID: make(map[uint32]gidCacheEntry)}
}
type gidCacheEntry struct {
gids map[uint32]struct{}
at time.Time
}
type nssResolver struct {
mu sync.Mutex
byUID map[uint32]gidCacheEntry
}
func (r *nssResolver) CallerGIDs(id Identity) map[uint32]struct{} {
r.mu.Lock()
defer r.mu.Unlock()
if e, ok := r.byUID[id.UID]; ok && time.Since(e.at) < groupCacheTTL {
return e.gids
}
gids := resolveGIDs(id.UID)
r.byUID[id.UID] = gidCacheEntry{gids: gids, at: time.Now()}
return gids
}
func resolveGIDs(uid uint32) map[uint32]struct{} {
out := make(map[uint32]struct{})
u, err := shell.GetUserFromGetent(strconv.FormatUint(uint64(uid), 10))
if err != nil {
return out
}
ids, err := shell.GroupIdsWithFallback(u)
if err != nil {
return out
}
for _, s := range ids {
if g, err := strconv.ParseUint(s, 10, 32); err == nil {
out[uint32(g)] = struct{}{}
}
}
return out
}
func (r *nssResolver) GroupNameGID(name string) (uint32, bool) {
g, err := shell.LookupGroupWithGetent(name)
if err != nil {
return 0, false
}
gid, err := strconv.ParseUint(g.Gid, 10, 32)
if err != nil {
return 0, false
}
return uint32(gid), true
}
@@ -0,0 +1,10 @@
//go:build windows
package ipcauth
// NewDefaultGroupResolver returns nil on Windows: group authorization uses the
// group SIDs carried in the client token (see the Windows transport
// credentials), not NSS/getent.
func NewDefaultGroupResolver() GroupResolver {
return nil
}
+28
View File
@@ -102,6 +102,11 @@ type ConfigInput struct {
DNSLabels domain.List
MTU *uint16
// Owners replaces the profile's owner principal list when non-nil.
// Shared replaces the profile's shared flag when non-nil.
Owners []string
Shared *bool
}
// Config Configuration type
@@ -184,6 +189,17 @@ type Config struct {
MTU uint16
// Owners lists the principals allowed to control this profile over the local
// IPC, as typed strings: "uid:1000", "gid:1000", "group:netbird-admins"
// (Unix, NSS-resolved) or "sid:S-1-5-..." (Windows user or group SID). Empty
// with Shared=false means the profile is owned by nobody yet (privileged +
// daemon-self only, until claimed). Interpreted by client/internal/ipcauth.
Owners []string `json:"Owners,omitempty"`
// Shared, when true, lets any authenticated local caller control this profile
// (opt-in). Takes precedence over Owners.
Shared bool `json:"Shared,omitempty"`
// policy is the MDM policy that produced the currently-set values for
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
// and reset on every apply() invocation. Never persisted to disk.
@@ -642,6 +658,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
if input.Owners != nil && !slices.Equal(config.Owners, input.Owners) {
log.Infof("updating profile owners to %v", input.Owners)
config.Owners = input.Owners
updated = true
}
if input.Shared != nil && *input.Shared != config.Shared {
log.Infof("updating profile shared flag to %t", *input.Shared)
config.Shared = *input.Shared
updated = true
}
// MDM is the last override layer: any key present in the policy
// supersedes defaults, on-disk config, env vars and CLI input.
config.applyMDMPolicy(loadMDMPolicy())
+4 -1
View File
@@ -296,7 +296,7 @@ func (s *ServiceManager) DefaultProfilePath() string {
// The returned Profile carries the freshly-generated ID so callers can
// show it to the user (and so the gRPC AddProfileResponse can include
// it).
func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) {
func (s *ServiceManager) AddProfile(displayName, username string, owners []string) (*Profile, error) {
configDir, err := s.getConfigDir(username)
if err != nil {
return nil, fmt.Errorf("failed to get config directory: %w", err)
@@ -318,6 +318,9 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err
return nil, fmt.Errorf("failed to create new config: %w", err)
}
cfg.Name = displayName
// owners auto-isolates the new profile to its creator (nil = unowned, so the
// eventual first caller claims it via trust-on-first-use).
cfg.Owners = owners
if err := util.WriteJson(context.Background(), profPath, cfg); err != nil {
return nil, fmt.Errorf("failed to write profile config: %w", err)
@@ -32,7 +32,7 @@ func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
func TestServiceProfile_ExactID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
created, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
got, err := sm.ResolveProfile(created.ID.String(), username)
@@ -44,7 +44,7 @@ func TestServiceProfile_ExactID(t *testing.T) {
func TestServiceProfile_IDPrefix(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
created, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
prefix := created.ID[:4]
@@ -75,7 +75,7 @@ func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
func TestServiceProfile_ExactNameUnique(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
_, err := sm.AddProfile("work", username)
_, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
got, err := sm.ResolveProfile("work", username)
@@ -86,9 +86,9 @@ func TestServiceProfile_ExactNameUnique(t *testing.T) {
func TestServiceProfile_AmbiguousName(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
_, err := sm.AddProfile("work", username)
_, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
_, err = sm.AddProfile("work", username)
_, err = sm.AddProfile("work", username, nil)
require.NoError(t, err)
_, err = sm.ResolveProfile("work", username)
@@ -133,10 +133,10 @@ func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
first, err := sm.AddProfile("work", username)
first, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
second, err := sm.AddProfile("work", username)
second, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID)
assert.Equal(t, "work", second.Name)
@@ -151,7 +151,7 @@ func TestAddProfile_RejectsInvalidNames(t *testing.T) {
strings.Repeat("a", maxProfileNameLen+1), // too long
}
for _, name := range cases {
_, err := sm.AddProfile(name, username)
_, err := sm.AddProfile(name, username, nil)
assert.Error(t, err, "expected error for %q", name)
}
})
@@ -215,7 +215,7 @@ func TestIsValidProfileFilenameStem(t *testing.T) {
func TestRemoveProfile_DeletesStateFile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
created, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
configDir, err := sm.getConfigDir(username)
+29
View File
@@ -0,0 +1,29 @@
//go:build cgo && !osusergo && !windows
package shell
import "os/user"
// LookupWithGetent with CGO delegates directly to os/user.Lookup.
// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through
// the NSS stack natively. If it fails, the user truly doesn't exist and
// getent would also fail.
func LookupWithGetent(username string) (*user.User, error) {
return user.Lookup(username)
}
// CurrentUserWithGetent with CGO delegates directly to os/user.Current.
func CurrentUserWithGetent() (*user.User, error) {
return user.Current()
}
// LookupGroupWithGetent returns the resolved group from either a gid or groupname.
func LookupGroupWithGetent(name string) (*user.Group, error) {
return user.LookupGroup(name)
}
// GroupIdsWithFallback with CGO delegates directly to user.GroupIds.
// libc's getgrouplist handles NSS groups natively.
func GroupIdsWithFallback(u *user.User) ([]string, error) {
return u.GroupIds()
}
@@ -0,0 +1,92 @@
//go:build (!cgo || osusergo) && !windows
package shell
import (
"os"
"os/user"
"strconv"
log "github.com/sirupsen/logrus"
)
// LookupWithGetent looks up a user by name, falling back to getent if os/user fails.
// Without CGO, os/user only reads /etc/passwd and misses NSS-provided users.
// getent goes through the host's NSS stack.
func LookupWithGetent(username string) (*user.User, error) {
u, err := user.Lookup(username)
if err == nil {
return u, nil
}
stdErr := err
log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err)
u, _, getentErr := runGetentPasswd(username)
if getentErr != nil {
log.Debugf("getent fallback for %q also failed: %v", username, getentErr)
return nil, stdErr
}
return u, nil
}
// LookupGroupWithGetent returns the resolved group from either a gid or groupname,
// falling back to getent if os/user fails (NSS groups under nocgo).
func LookupGroupWithGetent(name string) (*user.Group, error) {
g, err := user.LookupGroup(name)
if err == nil {
return g, nil
}
stdErr := err
log.Debugf("os/user.LookupGroup(%q) failed, trying getent: %v", name, err)
g, getentErr := runGetentGroup(name)
if getentErr != nil {
log.Debugf("getent fallback for %q also failed: %v", name, getentErr)
return nil, stdErr
}
return g, nil
}
// CurrentUserWithGetent gets the current user, falling back to getent if os/user fails.
func CurrentUserWithGetent() (*user.User, error) {
u, err := user.Current()
if err == nil {
return u, nil
}
stdErr := err
uid := strconv.Itoa(os.Getuid())
log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err)
u, _, getentErr := runGetentPasswd(uid)
if getentErr != nil {
return nil, stdErr
}
return u, nil
}
// GroupIdsWithFallback gets group IDs for a user via the id command first,
// falling back to user.GroupIds().
// NOTE: unlike LookupWithGetent/CurrentUserWithGetent which try stdlib first,
// this intentionally tries `id -G` first because without CGO, user.GroupIds()
// only reads /etc/group and silently returns incomplete results for NSS users
// (no error, just missing groups). The id command goes through NSS and returns
// the full set.
func GroupIdsWithFallback(u *user.User) ([]string, error) {
ids, err := runIdGroups(u.Username)
if err == nil {
return ids, nil
}
log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err)
ids, stdErr := u.GroupIds()
if stdErr != nil {
return nil, stdErr
}
return ids, nil
}
+172
View File
@@ -0,0 +1,172 @@
package shell
import (
"os/user"
"runtime"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLookupWithGetent_CurrentUser(t *testing.T) {
// The current user should always be resolvable on any platform
current, err := user.Current()
require.NoError(t, err)
u, err := LookupWithGetent(current.Username)
require.NoError(t, err)
assert.Equal(t, current.Username, u.Username)
assert.Equal(t, current.Uid, u.Uid)
assert.Equal(t, current.Gid, u.Gid)
}
func TestLookupWithGetent_NonexistentUser(t *testing.T) {
_, err := LookupWithGetent("nonexistent_user_xyzzy_12345")
require.Error(t, err, "should fail for nonexistent user")
}
func TestCurrentUserWithGetent(t *testing.T) {
stdUser, err := user.Current()
require.NoError(t, err)
u, err := CurrentUserWithGetent()
require.NoError(t, err)
assert.Equal(t, stdUser.Uid, u.Uid)
assert.Equal(t, stdUser.Username, u.Username)
}
func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
groups, err := GroupIdsWithFallback(current)
require.NoError(t, err)
require.NotEmpty(t, groups, "current user should have at least one group")
if runtime.GOOS != "windows" {
for _, gid := range groups {
_, err := strconv.ParseUint(gid, 10, 32)
assert.NoError(t, err, "group ID %q should be a valid uint32", gid)
}
}
}
func TestGetShellFromGetent_CurrentUser(t *testing.T) {
if runtime.GOOS == "windows" {
// Windows stub always returns empty, which is correct
shell := GetShellFromGetent("1000")
assert.Empty(t, shell, "Windows stub should return empty")
return
}
current, err := user.Current()
require.NoError(t, err)
// getent may not be available on all systems (e.g., macOS without Homebrew getent)
shell := GetShellFromGetent(current.Uid)
if shell == "" {
t.Log("GetShellFromGetent returned empty, getent may not be available")
return
}
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
}
func TestLookupWithGetent_RootUser(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no root user on Windows")
}
u, err := LookupWithGetent("root")
if err != nil {
t.Skip("root user not available on this system")
}
assert.Equal(t, "0", u.Uid, "root should have UID 0")
}
// TestIntegration_FullLookupChain exercises the complete user lookup chain
// against the real system, testing that all wrappers (LookupWithGetent,
// CurrentUserWithGetent, GroupIdsWithFallback, GetShellFromGetent) produce
// consistent and correct results when composed together.
func TestIntegration_FullLookupChain(t *testing.T) {
// Step 1: CurrentUserWithGetent must resolve the running user.
current, err := CurrentUserWithGetent()
require.NoError(t, err, "CurrentUserWithGetent must resolve the running user")
require.NotEmpty(t, current.Uid)
require.NotEmpty(t, current.Username)
// Step 2: LookupWithGetent by the same username must return matching identity.
byName, err := LookupWithGetent(current.Username)
require.NoError(t, err)
assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID")
assert.Equal(t, current.Gid, byName.Gid, "lookup by name should return same GID")
assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home")
// Step 3: GroupIdsWithFallback must return at least the primary GID.
groups, err := GroupIdsWithFallback(current)
require.NoError(t, err)
require.NotEmpty(t, groups, "user must have at least one group")
foundPrimary := false
for _, gid := range groups {
if runtime.GOOS != "windows" {
_, err := strconv.ParseUint(gid, 10, 32)
require.NoError(t, err, "group ID %q must be a valid uint32", gid)
}
if gid == current.Gid {
foundPrimary = true
}
}
assert.True(t, foundPrimary, "primary GID %s should appear in supplementary groups", current.Gid)
// Step 4: GetShellFromGetent should either return a valid shell path or empty
// (empty is OK when getent is not available, e.g. macOS without Homebrew getent).
if runtime.GOOS != "windows" {
shell := GetShellFromGetent(current.Uid)
if shell != "" {
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
}
}
}
// TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via
// LookupWithGetent can have their groups resolved via GroupIdsWithFallback,
// testing the handoff between the two functions as used by the SSH server.
func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
// Simulate the SSH server flow: lookup user, then get their groups.
resolved, err := LookupWithGetent(current.Username)
require.NoError(t, err)
groups, err := GroupIdsWithFallback(resolved)
require.NoError(t, err)
require.NotEmpty(t, groups, "resolved user must have groups")
// On Unix, all returned GIDs must be valid numeric values.
// On Windows, group IDs are SIDs (e.g., "S-1-5-32-544").
if runtime.GOOS != "windows" {
for _, gid := range groups {
_, err := strconv.ParseUint(gid, 10, 32)
assert.NoError(t, err, "group ID %q should be numeric", gid)
}
}
}
// TestIntegration_ShellLookupChain tests the full shell resolution chain
// (getShellFromPasswd -> GetShellFromGetent -> $SHELL -> default) on Unix.
func TestIntegration_ShellLookupChain(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix shell lookup not applicable on Windows")
}
current, err := user.Current()
require.NoError(t, err)
// GetUserShell is the top-level function used by the SSH server.
shell := GetUserShell(current.Uid)
require.NotEmpty(t, shell, "GetUserShell must always return a shell")
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
}
+165
View File
@@ -0,0 +1,165 @@
//go:build !windows
package shell
import (
"context"
"fmt"
"os/exec"
"os/user"
"runtime"
"strings"
"time"
)
const getentTimeout = 5 * time.Second
// GetShellFromGetent gets a user's login shell via getent by UID.
// This is needed even with CGO because getShellFromPasswd reads /etc/passwd
// directly and won't find NSS-provided users there.
func GetShellFromGetent(userID string) string {
_, shell, err := runGetentPasswd(userID)
if err != nil {
return ""
}
return shell
}
// GetUserFromGetent returns the resolved user from either a uid or username,
// going through the host's NSS stack.
func GetUserFromGetent(query string) (*user.User, error) {
u, _, err := runGetentPasswd(query)
return u, err
}
// runGetentPasswd executes `getent passwd <query>` and returns the user and login shell.
func runGetentPasswd(query string) (*user.User, string, error) {
if !validateGetentInput(query) {
return nil, "", fmt.Errorf("invalid getent input: %q", query)
}
ctx, cancel := context.WithTimeout(context.Background(), getentTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "getent", "passwd", query).Output()
if err != nil {
return nil, "", fmt.Errorf("getent passwd %s: %w", query, err)
}
return parseGetentPasswd(string(out))
}
// runGetentGroup executes `getent group <query>` and returns the group.
func runGetentGroup(query string) (*user.Group, error) {
if !validateGetentInput(query) {
return nil, fmt.Errorf("invalid getent input: %q", query)
}
ctx, cancel := context.WithTimeout(context.Background(), getentTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "getent", "group", query).Output()
if err != nil {
return nil, fmt.Errorf("getent group %s: %w", query, err)
}
return parseGetentGroup(string(out))
}
// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell".
func parseGetentPasswd(output string) (*user.User, string, error) {
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
if len(fields) < 6 {
return nil, "", fmt.Errorf("unexpected getent output (need 6+ fields): %q", output)
}
if fields[0] == "" || fields[2] == "" || fields[3] == "" {
return nil, "", fmt.Errorf("missing required fields in getent output: %q", output)
}
var shell string
if len(fields) >= 7 {
shell = fields[6]
}
return &user.User{
Username: fields[0],
Uid: fields[2],
Gid: fields[3],
Name: fields[4],
HomeDir: fields[5],
}, shell, nil
}
// parseGetentGroup parses getent group output: "group:x:gid:members".
func parseGetentGroup(output string) (*user.Group, error) {
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
if len(fields) < 4 {
return nil, fmt.Errorf("unexpected getent output (need 4+ fields): %q", output)
}
if fields[0] == "" || fields[2] == "" {
return nil, fmt.Errorf("missing required fields in getent output: %q", output)
}
return &user.Group{Gid: fields[2], Name: fields[0]}, nil
}
// validateGetentInput checks that the input is safe to pass to getent or id.
// Allows POSIX usernames, numeric UIDs, and common NSS extensions
// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is
// rejected so the input can never be parsed as a command-line flag.
func validateGetentInput(input string) bool {
maxLen := 32
if runtime.GOOS == "linux" {
maxLen = 256
}
if len(input) == 0 || len(input) > maxLen {
return false
}
if input[0] == '-' {
return false
}
for _, r := range input {
if isAllowedGetentChar(r) {
continue
}
return false
}
return true
}
func isAllowedGetentChar(r rune) bool {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
return true
}
switch r {
case '.', '_', '-', '@', '+', '$':
return true
}
return false
}
// runIdGroups runs `id -G <username>` and returns the space-separated group IDs.
func runIdGroups(username string) ([]string, error) {
if !validateGetentInput(username) {
return nil, fmt.Errorf("invalid username for id command: %q", username)
}
ctx, cancel := context.WithTimeout(context.Background(), getentTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "id", "-G", username).Output()
if err != nil {
return nil, fmt.Errorf("id -G %s: %w", username, err)
}
trimmed := strings.TrimSpace(string(out))
if trimmed == "" {
return nil, fmt.Errorf("id -G %s: empty output", username)
}
return strings.Fields(trimmed), nil
}
+413
View File
@@ -0,0 +1,413 @@
//go:build !windows
package shell
import (
"os/exec"
"os/user"
"runtime"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseGetentPasswd(t *testing.T) {
tests := []struct {
name string
input string
wantUser *user.User
wantShell string
wantErr bool
errContains string
}{
{
name: "standard entry",
input: "alice:x:1001:1001:Alice Smith:/home/alice:/bin/bash\n",
wantUser: &user.User{
Username: "alice",
Uid: "1001",
Gid: "1001",
Name: "Alice Smith",
HomeDir: "/home/alice",
},
wantShell: "/bin/bash",
},
{
name: "root entry",
input: "root:x:0:0:root:/root:/bin/bash",
wantUser: &user.User{
Username: "root",
Uid: "0",
Gid: "0",
Name: "root",
HomeDir: "/root",
},
wantShell: "/bin/bash",
},
{
name: "empty gecos field",
input: "svc:x:999:999::/var/lib/svc:/usr/sbin/nologin",
wantUser: &user.User{
Username: "svc",
Uid: "999",
Gid: "999",
Name: "",
HomeDir: "/var/lib/svc",
},
wantShell: "/usr/sbin/nologin",
},
{
name: "gecos with commas",
input: "john:x:1002:1002:John Doe,Room 101,555-1234,555-4321:/home/john:/bin/zsh",
wantUser: &user.User{
Username: "john",
Uid: "1002",
Gid: "1002",
Name: "John Doe,Room 101,555-1234,555-4321",
HomeDir: "/home/john",
},
wantShell: "/bin/zsh",
},
{
name: "remote user with large UID",
input: "remoteuser:*:50001:50001:Remote User:/home/remoteuser:/bin/bash\n",
wantUser: &user.User{
Username: "remoteuser",
Uid: "50001",
Gid: "50001",
Name: "Remote User",
HomeDir: "/home/remoteuser",
},
wantShell: "/bin/bash",
},
{
name: "no shell field (only 6 fields)",
input: "minimal:x:1000:1000::/home/minimal",
wantUser: &user.User{
Username: "minimal",
Uid: "1000",
Gid: "1000",
Name: "",
HomeDir: "/home/minimal",
},
wantShell: "",
},
{
name: "too few fields",
input: "bad:x:1000",
wantErr: true,
errContains: "need 6+ fields",
},
{
name: "empty username",
input: ":x:1000:1000::/home/test:/bin/bash",
wantErr: true,
errContains: "missing required fields",
},
{
name: "empty UID",
input: "test:x::1000::/home/test:/bin/bash",
wantErr: true,
errContains: "missing required fields",
},
{
name: "empty GID",
input: "test:x:1000:::/home/test:/bin/bash",
wantErr: true,
errContains: "missing required fields",
},
{
name: "empty input",
input: "",
wantErr: true,
errContains: "need 6+ fields",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, shell, err := parseGetentPasswd(tt.input)
if tt.wantErr {
require.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantUser.Username, u.Username, "username")
assert.Equal(t, tt.wantUser.Uid, u.Uid, "UID")
assert.Equal(t, tt.wantUser.Gid, u.Gid, "GID")
assert.Equal(t, tt.wantUser.Name, u.Name, "name/gecos")
assert.Equal(t, tt.wantUser.HomeDir, u.HomeDir, "home directory")
assert.Equal(t, tt.wantShell, shell, "shell")
})
}
}
func TestValidateGetentInput(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{"normal username", "alice", true},
{"numeric UID", "1001", true},
{"dots and underscores", "alice.bob_test", true},
{"hyphen", "alice-bob", true},
{"leading hyphen rejected", "-i", false},
{"leading double hyphen rejected", "--no-idn", false},
{"lone hyphen rejected", "-", false},
{"kerberos principal", "user@REALM", true},
{"samba machine account", "MACHINE$", true},
{"NIS compat", "+user", true},
{"empty", "", false},
{"null byte", "alice\x00bob", false},
{"newline", "alice\nbob", false},
{"tab", "alice\tbob", false},
{"control char", "alice\x01bob", false},
{"DEL char", "alice\x7fbob", false},
{"space rejected", "alice bob", false},
{"semicolon rejected", "alice;bob", false},
{"backtick rejected", "alice`bob", false},
{"pipe rejected", "alice|bob", false},
{"33 chars exceeds non-linux max", makeLongString(33), runtime.GOOS == "linux"},
{"256 chars at linux max", makeLongString(256), runtime.GOOS == "linux"},
{"257 chars exceeds all limits", makeLongString(257), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, validateGetentInput(tt.input))
})
}
}
func makeLongString(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = 'a'
}
return string(b)
}
func TestRunGetent_RootUser(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system")
}
u, shell, err := runGetentPasswd("root")
require.NoError(t, err)
assert.Equal(t, "root", u.Username)
assert.Equal(t, "0", u.Uid)
assert.Equal(t, "0", u.Gid)
assert.NotEmpty(t, shell, "root should have a shell")
}
func TestRunGetent_ByUID(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system")
}
u, _, err := runGetentPasswd("0")
require.NoError(t, err)
assert.Equal(t, "root", u.Username)
assert.Equal(t, "0", u.Uid)
}
func TestRunGetent_NonexistentUser(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system")
}
_, _, err := runGetentPasswd("nonexistent_user_xyzzy_12345")
assert.Error(t, err)
}
func TestRunGetent_InvalidInput(t *testing.T) {
_, _, err := runGetentPasswd("")
assert.Error(t, err)
_, _, err = runGetentPasswd("user\x00name")
assert.Error(t, err)
}
func TestRunGetent_NotAvailable(t *testing.T) {
if _, err := exec.LookPath("getent"); err == nil {
t.Skip("getent is available, can't test missing case")
}
_, _, err := runGetentPasswd("root")
assert.Error(t, err, "should fail when getent is not installed")
}
func TestRunIdGroups_CurrentUser(t *testing.T) {
if _, err := exec.LookPath("id"); err != nil {
t.Skip("id not available on this system")
}
current, err := user.Current()
require.NoError(t, err)
groups, err := runIdGroups(current.Username)
require.NoError(t, err)
require.NotEmpty(t, groups, "current user should have at least one group")
for _, gid := range groups {
_, err := strconv.ParseUint(gid, 10, 32)
assert.NoError(t, err, "group ID %q should be a valid uint32", gid)
}
}
func TestRunIdGroups_NonexistentUser(t *testing.T) {
if _, err := exec.LookPath("id"); err != nil {
t.Skip("id not available on this system")
}
_, err := runIdGroups("nonexistent_user_xyzzy_12345")
assert.Error(t, err)
}
func TestRunIdGroups_InvalidInput(t *testing.T) {
_, err := runIdGroups("")
assert.Error(t, err)
_, err = runIdGroups("user\x00name")
assert.Error(t, err)
}
func TestGetentResultsMatchStdlib(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system")
}
current, err := user.Current()
require.NoError(t, err)
getentUser, _, err := runGetentPasswd(current.Username)
require.NoError(t, err)
assert.Equal(t, current.Username, getentUser.Username, "username should match")
assert.Equal(t, current.Uid, getentUser.Uid, "UID should match")
assert.Equal(t, current.Gid, getentUser.Gid, "GID should match")
assert.Equal(t, current.HomeDir, getentUser.HomeDir, "home directory should match")
}
func TestGetentResultsMatchStdlib_ByUID(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system")
}
current, err := user.Current()
require.NoError(t, err)
getentUser, _, err := runGetentPasswd(current.Uid)
require.NoError(t, err)
assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID")
assert.Equal(t, current.Uid, getentUser.Uid, "UID should match")
}
func TestIdGroupsMatchStdlib(t *testing.T) {
if _, err := exec.LookPath("id"); err != nil {
t.Skip("id not available on this system")
}
current, err := user.Current()
require.NoError(t, err)
stdGroups, err := current.GroupIds()
if err != nil {
t.Skip("os/user.GroupIds() not working, likely CGO_ENABLED=0")
}
idGroups, err := runIdGroups(current.Username)
require.NoError(t, err)
// Deduplicate both lists: id -G can return duplicates (e.g., root in Docker)
// and ElementsMatch treats duplicates as distinct.
assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroups), "id -G should return same groups as os/user")
}
func uniqueStrings(ss []string) []string {
seen := make(map[string]struct{}, len(ss))
out := make([]string, 0, len(ss))
for _, s := range ss {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly
// reads the current user's shell from /etc/passwd by comparing it against what
// getent reports (which goes through NSS).
func TestGetShellFromPasswd_CurrentUser(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
shell := getShellFromPasswd(current.Uid)
if shell == "" {
t.Skip("current user not found in /etc/passwd (may be an NSS-only user)")
}
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
if _, err := exec.LookPath("getent"); err == nil {
_, getentShell, getentErr := runGetentPasswd(current.Uid)
if getentErr == nil && getentShell != "" {
assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent")
}
}
}
// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read
// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on
// any standard Unix system.
func TestGetShellFromPasswd_RootUser(t *testing.T) {
shell := getShellFromPasswd("0")
require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd")
assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell)
}
// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd
// returns empty for a UID that doesn't exist in /etc/passwd.
func TestGetShellFromPasswd_NonexistentUID(t *testing.T) {
shell := getShellFromPasswd("4294967294")
assert.Empty(t, shell, "nonexistent UID should return empty shell")
}
// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly
// and cross-validates every entry against getent to ensure parseGetentPasswd
// and getShellFromPasswd agree on shell values.
func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available")
}
// Pick a few well-known system UIDs that are virtually always in /etc/passwd.
uids := []string{"0"} // root
current, err := user.Current()
require.NoError(t, err)
uids = append(uids, current.Uid)
for _, uid := range uids {
passwdShell := getShellFromPasswd(uid)
if passwdShell == "" {
continue
}
_, getentShell, err := runGetentPasswd(uid)
if err != nil {
continue
}
assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid)
}
}
+31
View File
@@ -0,0 +1,31 @@
//go:build windows
package shell
import "os/user"
// LookupWithGetent on Windows just delegates to os/user.Lookup.
// Windows does not use NSS/getent; its user lookup works without CGO.
func LookupWithGetent(username string) (*user.User, error) {
return user.Lookup(username)
}
// CurrentUserWithGetent on Windows just delegates to os/user.Current.
func CurrentUserWithGetent() (*user.User, error) {
return user.Current()
}
// LookupGroupWithGetent on Windows just delegates to os/user.LookupGroup.
func LookupGroupWithGetent(name string) (*user.Group, error) {
return user.LookupGroup(name)
}
// GetShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection.
func GetShellFromGetent(_ string) string {
return ""
}
// GroupIdsWithFallback on Windows just delegates to u.GroupIds().
func GroupIdsWithFallback(u *user.User) ([]string, error) {
return u.GroupIds()
}
+155
View File
@@ -0,0 +1,155 @@
package shell
import (
"bufio"
"fmt"
"os"
"os/exec"
"os/user"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
)
const (
defaultUnixShell = "/bin/sh"
pwshExe = "pwsh.exe" // #nosec G101 - This is not a credential, just executable name
powershellExe = "powershell.exe"
)
// GetUserShell returns the appropriate shell for the given user ID.
// Handles all platform-specific logic and fallbacks consistently.
func GetUserShell(userID string) string {
switch runtime.GOOS {
case "windows":
return getWindowsUserShell()
default:
return getUnixUserShell(userID)
}
}
// getWindowsUserShell returns the best shell for Windows users.
// We intentionally do not support cmd.exe or COMSPEC fallbacks to avoid command injection
// vulnerabilities that arise from cmd.exe's complex command line parsing and special characters.
// PowerShell provides safer argument handling and is available on all modern Windows systems.
// Order: pwsh.exe -> powershell.exe
func getWindowsUserShell() string {
if path, err := exec.LookPath(pwshExe); err == nil {
return path
}
if path, err := exec.LookPath(powershellExe); err == nil {
return path
}
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`
}
// getUnixUserShell returns the shell for Unix-like systems.
// Tries /etc/passwd first (fast, no subprocess), falls back to getent for NSS users.
func getUnixUserShell(userID string) string {
if shell := getShellFromPasswd(userID); shell != "" {
return shell
}
if shell := GetShellFromGetent(userID); shell != "" {
return shell
}
if shell := os.Getenv("SHELL"); shell != "" {
return shell
}
return defaultUnixShell
}
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID.
func getShellFromPasswd(userID string) string {
file, err := os.Open("/etc/passwd")
if err != nil {
return ""
}
defer func() {
if err := file.Close(); err != nil {
log.Warnf("close /etc/passwd file: %v", err)
}
}()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Split(line, ":")
if len(fields) < 7 {
continue
}
// field 2 is UID
if fields[2] == userID {
shell := strings.TrimSpace(fields[6])
return shell
}
}
if err := scanner.Err(); err != nil {
log.Warnf("error reading /etc/passwd: %v", err)
}
return ""
}
// PrepareUserEnv prepares environment variables for user execution.
func PrepareUserEnv(user *user.User, shell string) []string {
pathValue := "/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games"
if runtime.GOOS == "windows" {
pathValue = `C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0`
}
return []string{
fmt.Sprint("SHELL=" + shell),
fmt.Sprint("USER=" + user.Username),
fmt.Sprint("LOGNAME=" + user.Username),
fmt.Sprint("HOME=" + user.HomeDir),
"PATH=" + pathValue,
}
}
// AcceptEnv checks if an environment variable from an SSH client should be accepted.
// This is a whitelist of variables that SSH clients can send to the server.
func AcceptEnv(envVar string) bool {
varName := envVar
if idx := strings.Index(envVar, "="); idx != -1 {
varName = envVar[:idx]
}
exactMatches := []string{
"LANG",
"LANGUAGE",
"TERM",
"COLORTERM",
"EDITOR",
"VISUAL",
"PAGER",
"LESS",
"LESSCHARSET",
"TZ",
}
prefixMatches := []string{
"LC_",
}
for _, exact := range exactMatches {
if varName == exact {
return true
}
}
for _, prefix := range prefixMatches {
if strings.HasPrefix(varName, prefix) {
return true
}
}
return false
}