ssh/tailssh: chdir to user's homedir when directly running a command (#15351)

Commit 4b525fdda (ssh/tailssh: only chdir incubator process to user's
homedir when necessary and possible, 2024-08-16) defers changing the
working directory until the incubator process drops its privileges.

However, it didn't account for the case where there is no incubator
process, because no tailscaled was found on the PATH. In that case, it
only intended to run `tailscaled be-child` in the root directory but
accidentally ran everything there.

Fixes: #15350

Signed-off-by: Simon Law <sfllaw@sfllaw.ca>
This commit is contained in:
Simon Law 2025-05-09 12:55:57 -07:00 committed by GitHub
parent 0841477743
commit 3c98964065
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -12,11 +12,13 @@
package tailssh
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
"log/syslog"
"os"
@ -29,6 +31,7 @@ import (
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/creack/pty"
"github.com/pkg/sftp"
@ -70,11 +73,36 @@ var maybeStartLoginSession = func(dlogf logger.Logf, ia incubatorArgs) (close fu
return nil
}
// tryExecInDir tries to run a command in dir and returns nil if it succeeds.
// Otherwise, it returns a filesystem error or a timeout error if the command
// took too long.
func tryExecInDir(ctx context.Context, dir string) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Assume that the following executables exist, are executable, and
// immediately return.
var name string
switch runtime.GOOS {
case "windows":
windir := os.Getenv("windir")
name = filepath.Join(windir, "system32", "doskey.exe")
default:
name = "/bin/true"
}
cmd := exec.CommandContext(ctx, name)
cmd.Dir = dir
return cmd.Run()
}
// newIncubatorCommand returns a new exec.Cmd configured with
// `tailscaled be-child ssh` as the entrypoint.
//
// If ss.srv.tailscaledPath is empty, this method is equivalent to
// exec.CommandContext.
// If ss.srv.tailscaledPath is empty, this method is almost equivalent to
// exec.CommandContext. It will refuse to run in SFTP-mode. It will simulate the
// behavior of SSHD when by falling back to the root directory if it cannot run
// a command in the users home directory.
//
// The returned Cmd.Env is guaranteed to be nil; the caller populates it.
func (ss *sshSession) newIncubatorCommand(logf logger.Logf) (cmd *exec.Cmd, err error) {
@ -104,7 +132,35 @@ func (ss *sshSession) newIncubatorCommand(logf logger.Logf) (cmd *exec.Cmd, err
loginShell := ss.conn.localUser.LoginShell()
args := shellArgs(isShell, ss.RawCommand())
logf("directly running %s %q", loginShell, args)
return exec.CommandContext(ss.ctx, loginShell, args...), nil
cmd = exec.CommandContext(ss.ctx, loginShell, args...)
// While running directly instead of using `tailscaled be-child`,
// do what sshd does by running inside the home directory,
// falling back to the root directory it doesn't have permissions.
// This can happen if the system has networked home directories,
// i.e. NFS or SMB, which enable root-squashing by default.
cmd.Dir = ss.conn.localUser.HomeDir
err := tryExecInDir(ss.ctx, cmd.Dir)
switch {
case errors.Is(err, exec.ErrNotFound):
// /bin/true might not be installed on a barebones system,
// so we assume that the home directory does not exist.
cmd.Dir = "/"
case errors.Is(err, fs.ErrPermission) || errors.Is(err, fs.ErrNotExist):
// Ensure that cmd.Dir is the source of the error.
var pathErr *fs.PathError
if errors.As(err, &pathErr) && pathErr.Path == cmd.Dir {
// If we cannot run loginShell in localUser.HomeDir,
// we will try to run this command in the root directory.
cmd.Dir = "/"
} else {
return nil, err
}
case err != nil:
return nil, err
}
return cmd, nil
}
lu := ss.conn.localUser
@ -178,7 +234,10 @@ func (ss *sshSession) newIncubatorCommand(logf logger.Logf) (cmd *exec.Cmd, err
}
}
return exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...), nil
cmd = exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...)
// The incubator will chdir into the home directory after it drops privileges.
cmd.Dir = "/"
return cmd, nil
}
var debugIncubator bool
@ -777,7 +836,6 @@ func (ss *sshSession) launchProcess() error {
}
cmd := ss.cmd
cmd.Dir = "/"
cmd.Env = envForUser(ss.conn.localUser)
for _, kv := range ss.Environ() {
if acceptEnvPair(kv) {