Files
netbird/client/ssh/server/userswitching_unix.go
2025-07-02 19:35:19 +02:00

246 lines
8.1 KiB
Go

//go:build unix
package server
import (
"errors"
"fmt"
"net"
"net/netip"
"os"
"os/exec"
"os/user"
"regexp"
"runtime"
"strconv"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
)
// POSIX portable filename character set regex: [a-zA-Z0-9._-]
// First character cannot be hyphen (POSIX requirement)
var posixUsernameRegex = regexp.MustCompile(`^[a-zA-Z0-9._][a-zA-Z0-9._-]*$`)
// validateUsername validates that a username conforms to POSIX standards with security considerations
func validateUsername(username string) error {
if username == "" {
return errors.New("username cannot be empty")
}
// POSIX allows up to 256 characters, but practical limit is 32 for compatibility
if len(username) > 32 {
return errors.New("username too long (max 32 characters)")
}
if !posixUsernameRegex.MatchString(username) {
return errors.New("username contains invalid characters (must match POSIX portable filename character set)")
}
if username == "." || username == ".." {
return fmt.Errorf("username cannot be '.' or '..'")
}
// Warn if username is fully numeric (can cause issues with UID/username ambiguity)
if isFullyNumeric(username) {
log.Warnf("fully numeric username '%s' may cause issues with some commands", username)
}
return nil
}
// isFullyNumeric checks if username contains only digits
func isFullyNumeric(username string) bool {
for _, char := range username {
if char < '0' || char > '9' {
return false
}
}
return true
}
// createSecurePtyUserSwitchCommand creates a Pty command with proper user switching
// For privileged processes, uses login command. For non-privileged, falls back to shell.
func (s *Server) createPtyUserSwitchCommand(_ []string, localUser *user.User, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) {
if !isCurrentProcessPrivileged() {
// Non-privileged process: fallback to shell with login flag
return s.createNonPrivilegedPtyCommand(localUser, ptyReq, session)
}
// Privileged process: use login command for proper user switching
return s.createPrivilegedPtyLoginCommand(localUser, ptyReq, session)
}
// createNonPrivilegedPtyCommand creates a Pty command for non-privileged processes
func (s *Server) createNonPrivilegedPtyCommand(localUser *user.User, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) {
shell := getUserShell(localUser.Uid)
args := []string{shell, "-l"}
execCmd := exec.CommandContext(session.Context(), args[0], args[1:]...)
execCmd.Dir = localUser.HomeDir
execCmd.Env = s.preparePtyEnv(localUser, ptyReq, session)
return execCmd, nil
}
// createPrivilegedPtyLoginCommand creates a Pty command using login for privileged processes
func (s *Server) createPrivilegedPtyLoginCommand(localUser *user.User, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) {
rawCmd := session.RawCommand()
// If there's a command to execute, use su -l -c instead of login
if rawCmd != "" {
return s.createPrivilegedPtySuCommand(localUser, ptyReq, session, rawCmd)
}
// For interactive sessions (no command), use login
loginPath, args, err := s.getRootLoginCmd(localUser.Username, session.RemoteAddr())
if err != nil {
return nil, fmt.Errorf("get login command: %w", err)
}
execCmd := exec.CommandContext(session.Context(), loginPath, args...)
execCmd.Dir = localUser.HomeDir
execCmd.Env = s.preparePtyEnv(localUser, ptyReq, session)
return execCmd, nil
}
// createPrivilegedPtySuCommand creates a Pty command using su -l -c for command execution
func (s *Server) createPrivilegedPtySuCommand(localUser *user.User, ptyReq ssh.Pty, session ssh.Session, command string) (*exec.Cmd, error) {
suPath, err := exec.LookPath("su")
if err != nil {
return nil, fmt.Errorf("su command not available: %w", err)
}
// Use su -l -c to execute the command as the target user with login environment
args := []string{"-l", localUser.Username, "-c", command}
execCmd := exec.CommandContext(session.Context(), suPath, args...)
execCmd.Dir = localUser.HomeDir
execCmd.Env = s.preparePtyEnv(localUser, ptyReq, session)
return execCmd, nil
}
// getRootLoginCmd returns the login command and args for privileged Pty user switching
func (s *Server) getRootLoginCmd(username string, remoteAddr net.Addr) (string, []string, error) {
loginPath, err := exec.LookPath("login")
if err != nil {
return "", nil, fmt.Errorf("login command not available: %w", err)
}
addrPort, err := netip.ParseAddrPort(remoteAddr.String())
if err != nil {
return "", nil, fmt.Errorf("parse remote address: %w", err)
}
switch runtime.GOOS {
case "linux":
// Special handling for Arch Linux without /etc/pam.d/remote
if s.fileExists("/etc/arch-release") && !s.fileExists("/etc/pam.d/remote") {
return loginPath, []string{"-f", username, "-p"}, nil
}
return loginPath, []string{"-f", username, "-h", addrPort.Addr().String(), "-p"}, nil
case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly":
return loginPath, []string{"-fp", "-h", addrPort.Addr().String(), username}, nil
default:
return "", nil, fmt.Errorf("unsupported Unix platform for login command: %s", runtime.GOOS)
}
}
// fileExists checks if a file exists (helper for login command logic)
func (s *Server) fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// parseUserCredentials extracts numeric UID, GID, and supplementary groups
func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []uint32, error) {
uid64, err := strconv.ParseUint(localUser.Uid, 10, 32)
if err != nil {
return 0, 0, nil, fmt.Errorf("invalid UID %s: %w", localUser.Uid, err)
}
uid := uint32(uid64)
gid64, err := strconv.ParseUint(localUser.Gid, 10, 32)
if err != nil {
return 0, 0, nil, fmt.Errorf("invalid GID %s: %w", localUser.Gid, err)
}
gid := uint32(gid64)
groups, err := s.getSupplementaryGroups(localUser.Username)
if err != nil {
log.Warnf("failed to get supplementary groups for user %s: %v", localUser.Username, err)
groups = []uint32{gid}
}
return uid, gid, groups, nil
}
// getSupplementaryGroups retrieves supplementary group IDs for a user
func (s *Server) getSupplementaryGroups(username string) ([]uint32, error) {
u, err := user.Lookup(username)
if err != nil {
return nil, fmt.Errorf("lookup user %s: %w", 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))
for i, gidStr := range groupIDStrings {
gid64, err := strconv.ParseUint(gidStr, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, username, err)
}
groups[i] = uint32(gid64)
}
return groups, nil
}
// createExecutorCommand creates a command that spawns netbird ssh exec for privilege dropping
func (s *Server) createExecutorCommand(session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, error) {
log.Debugf("creating executor command for user %s (Pty: %v)", localUser.Username, hasPty)
if err := validateUsername(localUser.Username); err != nil {
return nil, fmt.Errorf("invalid username: %w", err)
}
uid, gid, groups, err := s.parseUserCredentials(localUser)
if err != nil {
return nil, fmt.Errorf("parse user credentials: %w", err)
}
privilegeDropper := NewPrivilegeDropper()
config := ExecutorConfig{
UID: uid,
GID: gid,
Groups: groups,
WorkingDir: localUser.HomeDir,
Shell: getUserShell(localUser.Uid),
Command: session.RawCommand(),
PTY: hasPty,
}
return privilegeDropper.CreateExecutorCommand(session.Context(), config)
}
// createDirectCommand creates a command that runs without privilege dropping
func (s *Server) createDirectCommand(session ssh.Session, localUser *user.User) (*exec.Cmd, error) {
log.Debugf("creating direct command for user %s (no user switching needed)", localUser.Username)
shell := getUserShell(localUser.Uid)
args := s.getShellCommandArgs(shell, session.RawCommand())
cmd := exec.CommandContext(session.Context(), args[0], args[1:]...)
cmd.Dir = localUser.HomeDir
return cmd, nil
}
// enableUserSwitching is a no-op on Unix systems
func enableUserSwitching() error {
return nil
}