mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-05 09:03:54 -04:00
[client] Fall back to getent/id for SSH user lookup in static builds (#5510)
This commit is contained in:
24
client/ssh/server/getent_cgo_unix.go
Normal file
24
client/ssh/server/getent_cgo_unix.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build cgo && !osusergo && !windows
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
74
client/ssh/server/getent_nocgo_unix.go
Normal file
74
client/ssh/server/getent_nocgo_unix.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
//go:build (!cgo || osusergo) && !windows
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
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 := runGetent(username)
|
||||||
|
if getentErr != nil {
|
||||||
|
log.Debugf("getent fallback for %q also failed: %v", username, getentErr)
|
||||||
|
return nil, stdErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, 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 := runGetent(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
client/ssh/server/getent_test.go
Normal file
172
client/ssh/server/getent_test.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
122
client/ssh/server/getent_unix.go
Normal file
122
client/ssh/server/getent_unix.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
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 := runGetent(userID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return shell
|
||||||
|
}
|
||||||
|
|
||||||
|
// runGetent executes `getent passwd <query>` and returns the user and login shell.
|
||||||
|
func runGetent(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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
func validateGetentInput(input string) bool {
|
||||||
|
maxLen := 32
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
maxLen = 256
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(input) == 0 || len(input) > maxLen {
|
||||||
|
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
|
||||||
|
}
|
||||||
410
client/ssh/server/getent_unix_test.go
Normal file
410
client/ssh/server/getent_unix_test.go
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
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},
|
||||||
|
{"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 := runGetent("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 := runGetent("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 := runGetent("nonexistent_user_xyzzy_12345")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunGetent_InvalidInput(t *testing.T) {
|
||||||
|
_, _, err := runGetent("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
_, _, err = runGetent("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 := runGetent("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 := runGetent(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 := runGetent(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 := runGetent(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 := runGetent(uid)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
client/ssh/server/getent_windows.go
Normal file
26
client/ssh/server/getent_windows.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
@@ -49,10 +49,14 @@ func getWindowsUserShell() string {
|
|||||||
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`
|
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUnixUserShell returns the shell for Unix-like systems
|
// 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 {
|
func getUnixUserShell(userID string) string {
|
||||||
shell := getShellFromPasswd(userID)
|
if shell := getShellFromPasswd(userID); shell != "" {
|
||||||
if shell != "" {
|
return shell
|
||||||
|
}
|
||||||
|
|
||||||
|
if shell := getShellFromGetent(userID); shell != "" {
|
||||||
return shell
|
return shell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ func isPlatformUnix() bool {
|
|||||||
|
|
||||||
// Dependency injection variables for testing - allows mocking dynamic runtime checks
|
// Dependency injection variables for testing - allows mocking dynamic runtime checks
|
||||||
var (
|
var (
|
||||||
getCurrentUser = user.Current
|
getCurrentUser = currentUserWithGetent
|
||||||
lookupUser = user.Lookup
|
lookupUser = lookupWithGetent
|
||||||
getCurrentOS = func() string { return runtime.GOOS }
|
getCurrentOS = func() string { return runtime.GOOS }
|
||||||
getIsProcessPrivileged = isCurrentProcessPrivileged
|
getIsProcessPrivileged = isCurrentProcessPrivileged
|
||||||
|
|
||||||
|
|||||||
@@ -146,32 +146,30 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u
|
|||||||
}
|
}
|
||||||
gid := uint32(gid64)
|
gid := uint32(gid64)
|
||||||
|
|
||||||
groups, err := s.getSupplementaryGroups(localUser.Username)
|
groups, err := s.getSupplementaryGroups(localUser)
|
||||||
if err != nil {
|
if err != nil || len(groups) == 0 {
|
||||||
log.Warnf("failed to get supplementary groups for user %s: %v", localUser.Username, err)
|
if err != nil {
|
||||||
|
log.Warnf("failed to get supplementary groups for user %s: %v", localUser.Username, err)
|
||||||
|
}
|
||||||
groups = []uint32{gid}
|
groups = []uint32{gid}
|
||||||
}
|
}
|
||||||
|
|
||||||
return uid, gid, groups, nil
|
return uid, gid, groups, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSupplementaryGroups retrieves supplementary group IDs for a user
|
// getSupplementaryGroups retrieves supplementary group IDs for a user.
|
||||||
func (s *Server) getSupplementaryGroups(username string) ([]uint32, error) {
|
// Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds.
|
||||||
u, err := user.Lookup(username)
|
func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) {
|
||||||
|
groupIDStrings, err := groupIdsWithFallback(u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("lookup user %s: %w", username, err)
|
return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err)
|
||||||
}
|
|
||||||
|
|
||||||
groupIDStrings, err := u.GroupIds()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get group IDs for user %s: %w", username, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := make([]uint32, len(groupIDStrings))
|
groups := make([]uint32, len(groupIDStrings))
|
||||||
for i, gidStr := range groupIDStrings {
|
for i, gidStr := range groupIDStrings {
|
||||||
gid64, err := strconv.ParseUint(gidStr, 10, 32)
|
gid64, err := strconv.ParseUint(gidStr, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, username, err)
|
return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, u.Username, err)
|
||||||
}
|
}
|
||||||
groups[i] = uint32(gid64)
|
groups[i] = uint32(gid64)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user