Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/cmd/ssh_exec_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func init() {
sshExecCmd.Flags().UintSliceVar(&sshExecGroups, "groups", nil, "Supplementary group IDs (can be repeated)")
sshExecCmd.Flags().StringVar(&sshExecWorkingDir, "working-dir", "", "Working directory")
sshExecCmd.Flags().StringVar(&sshExecShell, "shell", "/bin/sh", "Shell to execute")
sshExecCmd.Flags().BoolVar(&sshExecPTY, "pty", false, "Request PTY (will fail as executor doesn't support PTY)")
sshExecCmd.Flags().BoolVar(&sshExecPTY, "pty", false, "Request PTY")
sshExecCmd.Flags().StringVar(&sshExecCommand, "cmd", "", "Command to execute")

if err := sshExecCmd.MarkFlagRequired("uid"); err != nil {
Expand Down
5 changes: 0 additions & 5 deletions client/ssh/server/command_execution_js.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,6 @@ func (s *Server) detectSuPtySupport(context.Context) bool {
return false
}

// detectUtilLinuxLogin always returns false on JS/WASM
func (s *Server) detectUtilLinuxLogin(context.Context) bool {
return false
}

// executeCommandWithPty is not supported on JS/WASM
func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, winCh <-chan ssh.Window) bool {
logger.Errorf("PTY command execution not supported on JS/WASM")
Expand Down
24 changes: 0 additions & 24 deletions client/ssh/server/command_execution_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
Expand Down Expand Up @@ -77,29 +76,6 @@ func (s *Server) detectSuPtySupport(ctx context.Context) bool {
return supported
}

// detectUtilLinuxLogin checks if login is from util-linux (vs shadow-utils).
// util-linux login uses vhangup() which requires setsid wrapper to avoid killing parent.
// See https://bugs.debian.org/1078023 for details.
func (s *Server) detectUtilLinuxLogin(ctx context.Context) bool {
if runtime.GOOS != "linux" {
return false
}

ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()

cmd := exec.CommandContext(ctx, "login", "--version")
output, err := cmd.CombinedOutput()
if err != nil {
log.Debugf("login --version failed (likely shadow-utils): %v", err)
return false
}

isUtilLinux := strings.Contains(string(output), "util-linux")
log.Debugf("util-linux login detected: %v", isUtilLinux)
return isUtilLinux
}

// createSuCommand creates a command using su - for privilege switching.
func (s *Server) createSuCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, error) {
if err := validateUsername(localUser.Username); err != nil {
Expand Down
5 changes: 0 additions & 5 deletions client/ssh/server/command_execution_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,11 +370,6 @@ func (s *Server) detectSuPtySupport(context.Context) bool {
return false
}

// detectUtilLinuxLogin always returns false on Windows
func (s *Server) detectUtilLinuxLogin(context.Context) bool {
return false
}

// executeCommandWithPty executes a command with PTY allocation on Windows using ConPty
func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _ *exec.Cmd, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, _ <-chan ssh.Window) bool {
localUser := privilegeResult.User
Expand Down
65 changes: 60 additions & 5 deletions client/ssh/server/executor_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"syscall"

log "github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
)

// Exit codes for executor process communication
Expand Down Expand Up @@ -213,11 +214,6 @@ func (pd *PrivilegeDropper) validateCurrentPrivileges(targetUID, targetGID uint3
func (pd *PrivilegeDropper) ExecuteWithPrivilegeDrop(ctx context.Context, config ExecutorConfig) {
log.Tracef("dropping privileges to UID=%d, GID=%d, groups=%v", config.UID, config.GID, config.Groups)

// TODO: Implement Pty support for executor path
if config.PTY {
config.PTY = false
}

if err := pd.DropPrivileges(config.UID, config.GID, config.Groups); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "privilege drop failed: %v\n", err)
os.Exit(ExitCodePrivilegeDropFail)
Expand All @@ -229,6 +225,28 @@ func (pd *PrivilegeDropper) ExecuteWithPrivilegeDrop(ctx context.Context, config
}
}

if config.PTY {
if err := pd.prepareControllingTerminal(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "PTY setup failed: %v\n", err)
os.Exit(ExitCodeShellExecFail)
}

argv := []string{"-" + filepath.Base(config.Shell)}
if config.Command == "" {
log.Tracef("executing login shell via exec: %s", config.Shell)
} else {
argv = append(argv, "-c", config.Command)
cmdParts := strings.Fields(config.Command)
safeCmd := safeLogCommand(cmdParts)
log.Tracef("executing %s -c %s via exec", config.Shell, safeCmd)
}

if err := syscall.Exec(config.Shell, argv, os.Environ()); err != nil {
Comment thread
pieterhollander marked this conversation as resolved.
log.Debugf("exec failed: %v", err)
os.Exit(ExitCodeShellExecFail)
}
}
Comment thread
pieterhollander marked this conversation as resolved.

var execCmd *exec.Cmd
if config.Command == "" {
execCmd = exec.CommandContext(ctx, config.Shell)
Expand Down Expand Up @@ -263,6 +281,43 @@ func (pd *PrivilegeDropper) ExecuteWithPrivilegeDrop(ctx context.Context, config
os.Exit(ExitCodeSuccess)
}

func (pd *PrivilegeDropper) prepareControllingTerminal() error {
if _, err := syscall.Setsid(); err != nil {
if errors.Is(err, syscall.EPERM) {
// EPERM can mean we're already a session leader or that a new session was not created.
// Only treat EPERM as non-fatal if we're already the session leader.
sid, gerr := unix.Getsid(0)
if gerr != nil {
return fmt.Errorf("setsid EPERM and getsid failed: %w", gerr)
}
if sid != os.Getpid() {
return fmt.Errorf("setsid: %w", err)
}
// Already a session leader; continue.
} else {
return fmt.Errorf("setsid: %w", err)
}
}

stdinFD := int(os.Stdin.Fd())
err := unix.IoctlSetInt(stdinFD, unix.TIOCSCTTY, 0)
if err == nil {
return nil
}

if errors.Is(err, syscall.EPERM) {
pd.log().Debugf("TIOCSCTTY failed with EPERM; possible causes include existing controlling terminal, not being a session leader, or container/seccomp restrictions; continuing: %v", err)
return nil
}

if errors.Is(err, syscall.ENOTTY) || errors.Is(err, syscall.EINVAL) {
pd.log().Debugf("stdin is not a PTY controlling terminal candidate, continuing: %v", err)
return nil
}

return fmt.Errorf("set controlling terminal: %w", err)
}

// validatePrivileges validates that privilege dropping to the target UID/GID is allowed
func (pd *PrivilegeDropper) validatePrivileges(uid, gid uint32) error {
currentUID := uint32(os.Geteuid())
Expand Down
48 changes: 38 additions & 10 deletions client/ssh/server/executor_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,15 @@ func TestPrivilegeDropper_ValidatePrivileges(t *testing.T) {

func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
pd := NewPrivilegeDropper()
currentUID := uint32(os.Geteuid())
currentGID := uint32(os.Getegid())
uidStr := strconv.FormatUint(uint64(currentUID), 10)
gidStr := strconv.FormatUint(uint64(currentGID), 10)

config := ExecutorConfig{
UID: 1000,
GID: 1000,
Groups: []uint32{1000, 1001},
UID: currentUID,
GID: currentGID,
Groups: []uint32{currentGID},
WorkingDir: "/home/testuser",
Shell: "/bin/bash",
Command: "ls -la",
Expand All @@ -93,12 +97,11 @@ func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
assert.Contains(t, cmd.Args, "ssh")
assert.Contains(t, cmd.Args, "exec")
assert.Contains(t, cmd.Args, "--uid")
assert.Contains(t, cmd.Args, "1000")
assert.Contains(t, cmd.Args, uidStr)
assert.Contains(t, cmd.Args, "--gid")
assert.Contains(t, cmd.Args, "1000")
assert.Contains(t, cmd.Args, gidStr)
assert.Contains(t, cmd.Args, "--groups")
assert.Contains(t, cmd.Args, "1000")
assert.Contains(t, cmd.Args, "1001")
assert.Contains(t, cmd.Args, gidStr)
assert.Contains(t, cmd.Args, "--working-dir")
assert.Contains(t, cmd.Args, "/home/testuser")
assert.Contains(t, cmd.Args, "--shell")
Expand All @@ -109,11 +112,13 @@ func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {

func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
pd := NewPrivilegeDropper()
currentUID := uint32(os.Geteuid())
currentGID := uint32(os.Getegid())

config := ExecutorConfig{
UID: 1000,
GID: 1000,
Groups: []uint32{1000},
UID: currentUID,
GID: currentGID,
Groups: []uint32{currentGID},
WorkingDir: "/home/testuser",
Shell: "/bin/bash",
Command: "",
Expand All @@ -128,6 +133,29 @@ func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
assert.NotContains(t, cmd.Args, "--interactive")
}

func TestPrivilegeDropper_CreateExecutorCommandWithPTY(t *testing.T) {
pd := NewPrivilegeDropper()
currentUID := uint32(os.Geteuid())
currentGID := uint32(os.Getegid())

config := ExecutorConfig{
UID: currentUID,
GID: currentGID,
Groups: []uint32{currentGID},
WorkingDir: "/home/testuser",
Shell: "/bin/bash",
Command: "",
PTY: true,
}

cmd, err := pd.CreateExecutorCommand(context.Background(), config)
require.NoError(t, err)
require.NotNil(t, cmd)

assert.Contains(t, cmd.Args, "--pty")
assert.NotContains(t, cmd.Args, "--cmd")
}

// TestPrivilegeDropper_ActualPrivilegeDrop tests actual privilege dropping
// This test requires root privileges and will be skipped if not running as root
func TestPrivilegeDropper_ActualPrivilegeDrop(t *testing.T) {
Expand Down
4 changes: 1 addition & 3 deletions client/ssh/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,7 @@ type Server struct {

authorizer *sshauth.Authorizer

suSupportsPty bool
loginIsUtilLinux bool
suSupportsPty bool
}

type JWTConfig struct {
Expand Down Expand Up @@ -227,7 +226,6 @@ func (s *Server) Start(ctx context.Context, addr netip.AddrPort) error {
}

s.suSupportsPty = s.detectSuPtySupport(ctx)
s.loginIsUtilLinux = s.detectUtilLinuxLogin(ctx)

ln, addrDesc, err := s.createListener(ctx, addr)
if err != nil {
Expand Down
23 changes: 6 additions & 17 deletions client/ssh/server/userswitching_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ func (s *Server) getLoginCmd(username string, remoteAddr net.Addr) (string, []st
}
}

// getLinuxLoginCmd returns the login command for Linux systems.
// Handles differences between util-linux and shadow-utils login implementations.
// getLinuxLoginCmd returns the login command and arguments for Linux systems.
// On Arch Linux without /etc/pam.d/remote the -h flag is omitted to avoid PAM failures.
func (s *Server) getLinuxLoginCmd(loginPath, username, remoteIP string) (string, []string) {
// Special handling for Arch Linux without /etc/pam.d/remote
var loginArgs []string
Expand All @@ -107,23 +107,12 @@ func (s *Server) getLinuxLoginCmd(loginPath, username, remoteIP string) (string,
loginArgs = []string{"-f", username, "-h", remoteIP, "-p"}
}

// util-linux login requires setsid -c to create a new session and set the
// controlling terminal. Without this, vhangup() kills the parent process.
// util-linux login uses vhangup() which requires a new session and controlling
// terminal. This is handled by pty.StartWithSize() which sets SysProcAttr.Setsid
// and SysProcAttr.Setctty on the command.
// See https://bugs.debian.org/1078023 for details.
Comment thread
pieterhollander marked this conversation as resolved.
// TODO: handle this via the executor using syscall.Setsid() + TIOCSCTTY + syscall.Exec()
// to avoid external setsid dependency.
if !s.loginIsUtilLinux {
return loginPath, loginArgs
}

setsidPath, err := exec.LookPath("setsid")
if err != nil {
log.Warnf("setsid not available but util-linux login detected, login may fail: %v", err)
return loginPath, loginArgs
}

args := append([]string{"-w", "-c", loginPath}, loginArgs...)
return setsidPath, args
return loginPath, loginArgs
}

// fileExists checks if a file exists
Expand Down
Loading