package server import ( "bufio" "fmt" "net" "os" "os/exec" "os/user" "runtime" "strconv" "strings" "github.com/gliderlabs/ssh" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal/getent" ) 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 } shell, err := getent.UserShell(userID) if err != nil { log.Debugf("look up the shell for uid %s through getent: %v", userID, err) } if 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 environment variable from SSH client should be accepted // This is a whitelist of variables that SSH clients can send to the server. // Accepted variables end up in the environment of login, su and the executor, // which start with the daemon's privileges, so the value is checked as well as the name. func acceptEnv(envVar string) bool { varName, value, _ := strings.Cut(envVar, "=") exactMatches := []string{ "LANG", "LANGUAGE", "TERM", "COLORTERM", "EDITOR", "VISUAL", "PAGER", "LESS", "LESSCHARSET", "TZ", } prefixMatches := []string{ "LC_", } for _, exact := range exactMatches { if varName == exact { return acceptEnvValue(varName, value) } } for _, prefix := range prefixMatches { if strings.HasPrefix(varName, prefix) { return acceptEnvValue(varName, value) } } return false } // acceptEnvValue rejects values that make libc open a file of the client's choosing // in a process that has not dropped privileges yet. Locale names are path elements of // the gettext message catalogs (which supply format strings to login and su), terminal // names of the terminfo entry and TZ names the time zone file. libc only guards against // this in setuid programs, not in programs started by root, so the same rules sudo // applies to these variables are enforced here. func acceptEnvValue(varName, value string) bool { switch { case varName == "TZ": zone := strings.TrimPrefix(value, ":") return !strings.HasPrefix(zone, "/") && !strings.Contains(zone, "..") case varName == "LANG", varName == "LANGUAGE", varName == "TERM", varName == "COLORTERM", strings.HasPrefix(varName, "LC_"): // ".." is a path component on its own: it needs no neighboring "/" to traverse // once concatenated into a locale, terminfo or message-catalog path. return !strings.ContainsAny(value, "/%") && !strings.Contains(value, "..") default: return true } } // prepareSSHEnv prepares SSH protocol-specific environment variables // These variables provide information about the SSH connection itself func prepareSSHEnv(session ssh.Session) []string { remoteAddr := session.RemoteAddr() localAddr := session.LocalAddr() remoteHost, remotePort, err := net.SplitHostPort(remoteAddr.String()) if err != nil { remoteHost = remoteAddr.String() remotePort = "0" } localHost, localPort, err := net.SplitHostPort(localAddr.String()) if err != nil { localHost = localAddr.String() localPort = strconv.Itoa(InternalSSHPort) } return []string{ // SSH_CLIENT format: "client_ip client_port server_port" fmt.Sprintf("SSH_CLIENT=%s %s %s", remoteHost, remotePort, localPort), // SSH_CONNECTION format: "client_ip client_port server_ip server_port" fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", remoteHost, remotePort, localHost, localPort), } }