git-lfs/ssh/ssh.go
brian m. carlson a106dcb1e0
ssh: avoid using -- where possible
In Git LFS version 3.0, we added support for understanding the
GIT_SSH_VARIANT environment variable and its corresponding configuration
option.  When we did so, we dropped the special-casing for program names
and instead always used the -- setting for the default variant, "ssh".
This variant represents OpenSSH, which as a program with normal POSIX
option handling, supports -- just fine.

Unfortunately, not all clients do.  TeamCity's SSH client doesn't set
the variant and also doesn't understand --.  We need to handle this case
to prevent option injection in case someone tries to use a URL like
"ssh://-oProxyCommand=exploit/", which could otherwise lead to arbitrary
code execution.

However, we don't have to add this all the time, but only when the
user-and-host portion starts with a dash.  The reason is that the only
other place an attacker could inject an option is in the path, and the
path is not a separate option by itself.  The path is always preceded by
a command (either "git-lfs-authenticate" or "git-lfs-transfer" in a
single option that contains the name of the command, the path, and the
operation, separated by spaces.  As a result, option injection is not
possible in the path since those commands don't take options.

Note that Git just dies in this case with a message like so:

  fatal: strange hostname '-oProxyCommand=exploit' blocked

Let's adopt this approach of using -- less often so that we can more
gracefully deal with this case and fix some SSH clients, even if they
really should learn to understand this option.  Update the test binary
to expect the new results as well.

Since we're adjusting the end-of-options delimiter such that it's only
used for the default SSH variant and, in particular, only when the
user/host parameter starts with a "-" character, we still need to accept
that command argument format in our lfs-ssh-echo test helper program, so
we keep that logic, but also simplify our logic for parsing the
program's arguments so it will be, with luck, easier to maintain in the
future.

Note that we also remove the "$4" from one error message
because it's no longer the case that that error condition
always arises due to the fourth argument.

Co-authored-by: Chris Darroch <chrisd8088@github.com>
2021-11-30 20:20:58 +00:00

202 lines
5.5 KiB
Go

package ssh
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"syscall"
"github.com/git-lfs/git-lfs/v3/config"
"github.com/git-lfs/git-lfs/v3/subprocess"
"github.com/git-lfs/git-lfs/v3/tools"
"github.com/rubyist/tracerx"
)
type sshVariant string
const (
variantSSH = sshVariant("ssh")
variantSimple = sshVariant("simple")
variantPutty = sshVariant("putty")
variantTortoise = sshVariant("tortoiseplink")
)
type SSHMetadata struct {
UserAndHost string
Port string
Path string
}
func FormatArgs(cmd string, args []string, needShell bool) (string, []string) {
if !needShell {
return cmd, args
}
return subprocess.FormatForShellQuotedArgs(cmd, args)
}
func GetLFSExeAndArgs(osEnv config.Environment, gitEnv config.Environment, meta *SSHMetadata, command, operation string, multiplexDesired bool) (string, []string) {
exe, args, needShell := GetExeAndArgs(osEnv, gitEnv, meta, multiplexDesired)
args = append(args, fmt.Sprintf("%s %s %s", command, meta.Path, operation))
exe, args = FormatArgs(exe, args, needShell)
tracerx.Printf("run_command: %s %s", exe, strings.Join(args, " "))
return exe, args
}
// Parse command, and if it looks like a valid command, return the ssh binary
// name, the command to run, and whether we need a shell. If not, return
// existing as the ssh binary name.
func parseShellCommand(command string, existing string) (ssh string, cmd string, needShell bool) {
ssh = existing
if cmdArgs := tools.QuotedFields(command); len(cmdArgs) > 0 {
needShell = true
ssh = cmdArgs[0]
cmd = command
}
return
}
func findVariant(variant string) (bool, sshVariant) {
switch variant {
case "ssh", "simple", "putty", "tortoiseplink":
return false, sshVariant(variant)
case "plink":
return false, variantPutty
case "auto":
return true, ""
default:
return false, variantSSH
}
}
func autodetectVariant(osEnv config.Environment, gitEnv config.Environment, basessh string) sshVariant {
if basessh != defaultSSHCmd {
// Strip extension for easier comparison
if ext := filepath.Ext(basessh); len(ext) > 0 {
basessh = basessh[:len(basessh)-len(ext)]
}
if strings.EqualFold(basessh, "plink") {
return variantPutty
}
if strings.EqualFold(basessh, "tortoiseplink") {
return variantTortoise
}
}
return "ssh"
}
func getVariant(osEnv config.Environment, gitEnv config.Environment, basessh string) sshVariant {
variant, ok := osEnv.Get("GIT_SSH_VARIANT")
if !ok {
variant, ok = gitEnv.Get("ssh.variant")
}
autodetect, val := findVariant(variant)
if ok && !autodetect {
return val
}
return autodetectVariant(osEnv, gitEnv, basessh)
}
// findRuntimeDir returns a path to the runtime directory if one exists and is
// guaranteed to be private.
func findRuntimeDir(osEnv config.Environment) string {
if dir, ok := osEnv.Get("XDG_RUNTIME_DIR"); ok {
return dir
}
return ""
}
func getControlDir(osEnv config.Environment) (string, error) {
dir := findRuntimeDir(osEnv)
if dir == "" {
return ioutil.TempDir("", "sock-*")
}
dir = filepath.Join(dir, "git-lfs")
err := os.Mkdir(dir, 0700)
if err != nil {
// Ideally we would use errors.Is here to check against
// os.ErrExist, but that's not available on Go 1.11.
perr, ok := err.(*os.PathError)
if !ok || perr.Err != syscall.EEXIST {
return ioutil.TempDir("", "sock-*")
}
}
return dir, nil
}
// Return the executable name for ssh on this machine and the base args
// Base args includes port settings, user/host, everything pre the command to execute
func GetExeAndArgs(osEnv config.Environment, gitEnv config.Environment, meta *SSHMetadata, multiplexDesired bool) (exe string, baseargs []string, needShell bool) {
var cmd string
ssh, _ := osEnv.Get("GIT_SSH")
sshCmd, _ := osEnv.Get("GIT_SSH_COMMAND")
ssh, cmd, needShell = parseShellCommand(sshCmd, ssh)
if ssh == "" {
sshCmd, _ := gitEnv.Get("core.sshcommand")
ssh, cmd, needShell = parseShellCommand(sshCmd, defaultSSHCmd)
}
if cmd == "" {
cmd = ssh
}
basessh := filepath.Base(ssh)
variant := getVariant(osEnv, gitEnv, basessh)
args := make([]string, 0, 7)
if variant == variantTortoise {
// TortoisePlink requires the -batch argument to behave like ssh/plink
args = append(args, "-batch")
}
multiplexEnabled := gitEnv.Bool("lfs.ssh.automultiplex", true)
if variant == variantSSH && multiplexDesired && multiplexEnabled {
controlPath, err := getControlDir(osEnv)
if err != nil {
controlPath = filepath.Join(controlPath, "sock-%C")
args = append(args, "-oControlMaster=auto", fmt.Sprintf("-oControlPath=%s", controlPath))
}
}
if len(meta.Port) > 0 {
if variant == variantPutty || variant == variantTortoise {
args = append(args, "-P")
} else {
args = append(args, "-p")
}
args = append(args, meta.Port)
}
if sshOptPrefixRE.MatchString(meta.UserAndHost) {
if variant == variantSSH {
// inserts a separator between cli -options and host/cmd commands
// example: $ ssh -p 12345 -- user@host.com git-lfs-authenticate ...
args = append(args, "--", meta.UserAndHost)
} else {
// no prefix supported, strip leading - off host to prevent cmd like:
// $ git config lfs.url ssh://-proxycmd=whatever
// $ plink -P 12345 -proxycmd=foo git-lfs-authenticate ...
//
// Instead, it'll attempt this, and eventually return an error
// $ plink -P 12345 proxycmd=foo git-lfs-authenticate ...
args = append(args, sshOptPrefixRE.ReplaceAllString(meta.UserAndHost, ""))
}
} else {
args = append(args, meta.UserAndHost)
}
return cmd, args, needShell
}
const defaultSSHCmd = "ssh"
var (
sshOptPrefixRE = regexp.MustCompile(`\A\-+`)
)