Skip to content

Commit b5868f6

Browse files
committed
fix: Use smarter quoting for ProxyCommand in config-ssh
This change takes better into account how OpenSSH executes `ProxyCommand`s and applies quoting accordingly. This supercedes #3664, which was reverted. Fixes #2853
1 parent 20086c1 commit b5868f6

File tree

1 file changed

+65
-6
lines changed

1 file changed

+65
-6
lines changed

cli/configssh.go

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,20 @@ func configSSH() *cobra.Command {
170170
// that it's possible to capture the diff.
171171
out = cmd.OutOrStderr()
172172
}
173-
binaryFile, err := currentBinPath(out)
173+
coderBinary, err := currentBinPath(out)
174174
if err != nil {
175175
return err
176176
}
177+
escapedCoderBinary, err := sshConfigExecEscape(coderBinary)
178+
if err != nil {
179+
return xerrors.Errorf("escape coder binary for ssh failed: %w", err)
180+
}
181+
182+
root := createConfig(cmd)
183+
escapedGlobalConfig, err := sshConfigExecEscape(string(root))
184+
if err != nil {
185+
return xerrors.Errorf("escape global config for ssh failed: %w", err)
186+
}
177187

178188
homedir, err := os.UserHomeDir()
179189
if err != nil {
@@ -238,7 +248,6 @@ func configSSH() *cobra.Command {
238248
}
239249

240250
configModified := configRaw
241-
root := createConfig(cmd)
242251

243252
buf := &bytes.Buffer{}
244253
before, after := sshConfigSplitOnCoderSection(configModified)
@@ -280,11 +289,17 @@ func configSSH() *cobra.Command {
280289
"\tLogLevel ERROR",
281290
)
282291
if !skipProxyCommand {
283-
if !wireguard {
284-
configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname))
285-
} else {
286-
configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --wireguard --stdio %s", binaryFile, root, hostname))
292+
wgArg := ""
293+
if wireguard {
294+
wgArg = "--wireguard "
287295
}
296+
configOptions = append(
297+
configOptions,
298+
fmt.Sprintf(
299+
"\tProxyCommand %s --global-config %s ssh %s--stdio %s",
300+
escapedCoderBinary, escapedGlobalConfig, wgArg, hostname,
301+
),
302+
)
288303
}
289304

290305
_, _ = buf.WriteString(strings.Join(configOptions, "\n"))
@@ -482,6 +497,50 @@ func writeWithTempFileAndMove(path string, r io.Reader) (err error) {
482497
return nil
483498
}
484499

500+
// sshConfigExecEscape quotes the string if it contains spaces, as per
501+
// `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to
502+
// run the command, and as such the formatting/escape requirements
503+
// cannot simply be covered by `fmt.Sprintf("%q", path)`.
504+
//
505+
// Always escaping the path with `fmt.Sprintf("%q", path)` usually works
506+
// on most platforms, but double quotes sometimes break on Windows 10
507+
// (see #2853). This function takes a best-effort approach to improving
508+
// compatibility and covering edge cases.
509+
//
510+
// Given the following ProxyCommand:
511+
//
512+
// ProxyCommand "/path/with space/coder" ssh --stdio work
513+
//
514+
// This is ~what OpenSSH would execute:
515+
//
516+
// /bin/bash -c '"/path/with space/to/coder" ssh --stdio workspace'
517+
//
518+
// However, since it's actually an arg in C, the contents inside the
519+
// single quotes are interpreted as is, e.g. if there was a '\t', it
520+
// would be the literal string '\t', not a tab.
521+
//
522+
// See:
523+
// - https://github.com/coder/coder/issues/2853
524+
// - https://github.com/openssh/openssh-portable/blob/V_9_0_P1/sshconnect.c#L158-L167
525+
func sshConfigExecEscape(path string) (string, error) {
526+
// This is unlikely to ever happen, but newlines are allowed on
527+
// certain filesystems, but cannot be used inside ssh config.
528+
if strings.ContainsAny(path, "\n") {
529+
return "", xerrors.Errorf("invalid path: %s", path)
530+
}
531+
// In the unlikely even that a path contains quotes, they must be
532+
// escaped so that they are not interpreted as shell quotes.
533+
if strings.Contains(path, "\"") {
534+
path = strings.ReplaceAll(path, "\"", "\\\"")
535+
}
536+
// A space or a tab requires quoting, but tabs must not be escaped
537+
// (\t) since OpenSSH interprets it as a literal \t, not a tab.
538+
if strings.ContainsAny(path, " \t") {
539+
path = fmt.Sprintf("\"%s\"", path)
540+
}
541+
return path, nil
542+
}
543+
485544
// currentBinPath returns the path to the coder binary suitable for use in ssh
486545
// ProxyCommand.
487546
func currentBinPath(w io.Writer) (string, error) {

0 commit comments

Comments
 (0)