Skip to content

Commit 69112fa

Browse files
committed
fix: fix handling coder path with spaces in config-ssh
1 parent 08eff7f commit 69112fa

File tree

3 files changed

+73
-13
lines changed

3 files changed

+73
-13
lines changed

cli/configssh.go

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -112,14 +112,19 @@ func (o sshConfigOptions) equal(other sshConfigOptions) bool {
112112
}
113113

114114
func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error {
115-
escapedCoderBinary, err := sshConfigExecEscape(o.coderBinaryPath, o.forceUnixSeparators)
115+
escapedCoderBinaryProxy, err := sshConfigProxyCommandEscape(o.coderBinaryPath, o.forceUnixSeparators)
116116
if err != nil {
117-
return xerrors.Errorf("escape coder binary for ssh failed: %w", err)
117+
return xerrors.Errorf("escape coder binary for ProxyCommand failed: %w", err)
118118
}
119119

120-
escapedGlobalConfig, err := sshConfigExecEscape(o.globalConfigPath, o.forceUnixSeparators)
120+
escapedCoderBinaryMatchExec, err := sshConfigMatchExecEscape(o.coderBinaryPath)
121121
if err != nil {
122-
return xerrors.Errorf("escape global config for ssh failed: %w", err)
122+
return xerrors.Errorf("escape coder binary for Match exec failed: %w", err)
123+
}
124+
125+
escapedGlobalConfig, err := sshConfigProxyCommandEscape(o.globalConfigPath, o.forceUnixSeparators)
126+
if err != nil {
127+
return xerrors.Errorf("escape global config for ProxyCommand failed: %w", err)
123128
}
124129

125130
rootFlags := fmt.Sprintf("--global-config %s", escapedGlobalConfig)
@@ -155,7 +160,7 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error {
155160
_, _ = buf.WriteString("\t")
156161
_, _ = fmt.Fprintf(buf,
157162
"ProxyCommand %s %s ssh --stdio%s --ssh-host-prefix %s %%h",
158-
escapedCoderBinary, rootFlags, flags, o.userHostPrefix,
163+
escapedCoderBinaryProxy, rootFlags, flags, o.userHostPrefix,
159164
)
160165
_, _ = buf.WriteString("\n")
161166
}
@@ -174,11 +179,11 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error {
174179
// the ^^ options should always apply, but we only want to use the proxy command if Coder Connect is not running.
175180
if !o.skipProxyCommand {
176181
_, _ = fmt.Fprintf(buf, "\nMatch host *.%s !exec \"%s connect exists %%h\"\n",
177-
o.hostnameSuffix, escapedCoderBinary)
182+
o.hostnameSuffix, escapedCoderBinaryMatchExec)
178183
_, _ = buf.WriteString("\t")
179184
_, _ = fmt.Fprintf(buf,
180185
"ProxyCommand %s %s ssh --stdio%s --hostname-suffix %s %%h",
181-
escapedCoderBinary, rootFlags, flags, o.hostnameSuffix,
186+
escapedCoderBinaryProxy, rootFlags, flags, o.hostnameSuffix,
182187
)
183188
_, _ = buf.WriteString("\n")
184189
}
@@ -759,7 +764,8 @@ func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after []
759764
return data, nil, nil, nil
760765
}
761766

762-
// sshConfigExecEscape quotes the string if it contains spaces, as per
767+
// sshConfigProxyCommandEscape prepares the path for use in ProxyCommand.
768+
// It quotes the string if it contains spaces, as per
763769
// `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to
764770
// run the command, and as such the formatting/escape requirements
765771
// cannot simply be covered by `fmt.Sprintf("%q", path)`.
@@ -804,7 +810,7 @@ func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after []
804810
// This is a control flag, and that is ok. It is a control flag
805811
// based on the OS of the user. Making this a different file is excessive.
806812
// nolint:revive
807-
func sshConfigExecEscape(path string, forceUnixPath bool) (string, error) {
813+
func sshConfigProxyCommandEscape(path string, forceUnixPath bool) (string, error) {
808814
if forceUnixPath {
809815
// This is a workaround for #7639, where the filepath separator is
810816
// incorrectly the Windows separator (\) instead of the unix separator (/).
@@ -814,9 +820,9 @@ func sshConfigExecEscape(path string, forceUnixPath bool) (string, error) {
814820
// This is unlikely to ever happen, but newlines are allowed on
815821
// certain filesystems, but cannot be used inside ssh config.
816822
if strings.ContainsAny(path, "\n") {
817-
return "", xerrors.Errorf("invalid path: %s", path)
823+
return "", xerrors.Errorf("invalid path: %q", path)
818824
}
819-
// In the unlikely even that a path contains quotes, they must be
825+
// In the unlikely event that a path contains quotes, they must be
820826
// escaped so that they are not interpreted as shell quotes.
821827
if strings.Contains(path, "\"") {
822828
path = strings.ReplaceAll(path, "\"", "\\\"")

cli/configssh_internal_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ func Test_sshConfigExecEscape(t *testing.T) {
171171
err = os.WriteFile(bin, contents, 0o755) //nolint:gosec
172172
require.NoError(t, err)
173173

174-
escaped, err := sshConfigExecEscape(bin, false)
174+
escaped, err := sshConfigProxyCommandEscape(bin, false)
175175
if tt.wantErr {
176176
require.Error(t, err)
177177
return
@@ -236,7 +236,7 @@ func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) {
236236
tt := tt
237237
t.Run(tt.name, func(t *testing.T) {
238238
t.Parallel()
239-
found, err := sshConfigExecEscape(tt.path, tt.forceUnix)
239+
found, err := sshConfigProxyCommandEscape(tt.path, tt.forceUnix)
240240
if tt.wantErr {
241241
require.Error(t, err)
242242
return

cli/configssh_windows.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,59 @@
22

33
package cli
44

5+
import (
6+
"fmt"
7+
"strings"
8+
9+
"golang.org/x/xerrors"
10+
)
11+
512
// Must be a var for unit tests to conform behavior
613
var hideForceUnixSlashes = false
14+
15+
// sshConfigMatchExecEscape prepares the path for use in `Match exec` statement.
16+
//
17+
// OpenSSH parses the Match line with a very simple tokenizer that accepts "-enclosed strings for the exec command, and
18+
// has no supported escape sequences for ". This means we cannot include " within the command to execute.
19+
//
20+
// To make matters worse, on Windows, OpenSSH passes the string directly to cmd.exe for execution, and as far as I can
21+
// tell, the only supported way to call a path that has spaces in it is to surround it with ".
22+
//
23+
// So, we can't actually include " directly, but here is a horrible workaround:
24+
//
25+
// "for /f %%a in ('powershell.exe -Command [char]34') do @cmd.exe /c %%aC:\Program Files\Coder\bin\coder.exe%%a connect exists %h"
26+
//
27+
// The key insight here is to store the character " in a variable (%a in this case, but the % itself needs to be
28+
// escaped, so it becomes %%a), and then use that variable to construct the double-quoted path:
29+
//
30+
// %%aC:\Program Files\Coder\bin\coder.exe%%a.
31+
//
32+
// How do we generate a single " character without actually using that character? I couldn't find any command in cmd.exe
33+
// to do it, but powershell.exe can convert ASCII to characters like this: `[char]34` (where 34 is the code point for ").
34+
//
35+
// Other notes:
36+
// - @ in `@cmd.exe` suppresses echoing it, so you don't get this command printed
37+
// - without another invocation of cmd.exe (e.g. `do @cmd.exe /c %%aC:\Program Files\Coder\bin\coder.exe%%a`) then
38+
// the double-quote gets interpreted as part of the path and you get: '"C:\Program' is not recognized. Constructing
39+
// the string and then passing it to another instance of cmd.exe does this trick here.
40+
// - OpenSSH passes the `Match exec` command to cmd.exe regardless of whether the user has a unix-like shell like
41+
// git bash, so we don't have a `forceUnixPath` option like for the ProxyCommand which does respect the user's
42+
// configured shell.
43+
func sshConfigMatchExecEscape(path string) (string, error) {
44+
// This is unlikely to ever happen, but newlines are allowed on
45+
// certain filesystems, but cannot be used inside ssh config.
46+
if strings.ContainsAny(path, "\n") {
47+
return "", xerrors.Errorf("invalid path: %s", path)
48+
}
49+
// Windows does not allow double-quotes in paths. If we get one it is an error.
50+
if strings.Contains(path, `"`) {
51+
return "", xerrors.Errorf("path must not contain quotes: %q", path)
52+
}
53+
// A space or a tab requires quoting, but tabs must not be escaped
54+
// (\t) since OpenSSH interprets it as a literal \t, not a tab.
55+
if strings.ContainsAny(path, " \t") {
56+
// c.f. function comment for how this works.
57+
path = fmt.Sprintf("for /f %%%%a in ('powershell.exe -Command [char]34') do @cmd.exe /c %%%%a%s%%%%a", path) //nolint:gocritic // We don't want %q here.
58+
}
59+
return path, nil
60+
}

0 commit comments

Comments
 (0)