Skip to content

Commit 39f762e

Browse files
committed
feature: read force-command from client certificate
1 parent 0e47d77 commit 39f762e

File tree

117 files changed

+18499
-2
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

117 files changed

+18499
-2
lines changed

cmd/sshproxy/sshproxy.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
package main
1212

1313
import (
14+
"bytes"
1415
"context"
1516
"errors"
1617
"flag"
@@ -34,6 +35,7 @@ import (
3435
"github.com/moby/term"
3536
"github.com/op/go-logging"
3637
"go.etcd.io/etcd/client/v3"
38+
"golang.org/x/crypto/ssh"
3739
)
3840

3941
var (
@@ -206,6 +208,42 @@ type ConnInfo struct {
206208
SSH *SSHInfo // SSH source and destination (from SSH_CONNECTION)
207209
}
208210

211+
// GetOriginalCommand returns the force-command included in the client ssh
212+
// certificate, if any. Otherwise, it returns the content of the environment
213+
// variable SSH_ORIGINAL_COMMAND. No error is returned. In case of any error,
214+
// the content of SSH_ORIGINAL_COMMAND will be returned. The second returned
215+
// string is a comment for debugging purpose.
216+
func getOriginalCommand() (string, string) {
217+
userAuthFile := os.Getenv("SSH_USER_AUTH")
218+
if userAuthFile != "" {
219+
// The environment variable is present
220+
content, err := os.ReadFile(userAuthFile)
221+
if err == nil {
222+
// The temporary file is read
223+
prefix := []byte("publickey ")
224+
key, found := bytes.CutPrefix(content, prefix)
225+
if found {
226+
// The file contains a publickey
227+
pubKey, _, _, _, err := ssh.ParseAuthorizedKey(key)
228+
if err == nil {
229+
// The pubilckey is parsed
230+
cert, ok := pubKey.(*ssh.Certificate)
231+
if ok && cert.Permissions.CriticalOptions != nil && cert.Permissions.CriticalOptions["force-command"] != "" {
232+
// the publickey is a certificate, and contains a
233+
// force-command
234+
return cert.Permissions.CriticalOptions["force-command"], " (forced) "
235+
}
236+
} else {
237+
log.Warning(err)
238+
}
239+
}
240+
} else {
241+
log.Warning(err)
242+
}
243+
}
244+
return os.Getenv("SSH_ORIGINAL_COMMAND"), " "
245+
}
246+
209247
func main() {
210248
os.Exit(mainExitCode())
211249
}
@@ -428,8 +466,8 @@ func mainExitCode() int {
428466
}
429467
}()
430468

431-
originalCmd := os.Getenv("SSH_ORIGINAL_COMMAND")
432-
log.Debugf("original command = %s", originalCmd)
469+
originalCmd, comment := getOriginalCommand()
470+
log.Debugf("original command%s= %s", comment, originalCmd)
433471

434472
interactiveCommand := term.IsTerminal(os.Stdout.Fd())
435473
log.Debugf("interactiveCommand = %v", interactiveCommand)

cmd/sshproxy/sshproxy_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,3 +530,86 @@ func BenchmarkSrcDst(b *testing.B) {
530530
}
531531
})
532532
}
533+
534+
var getOriginalCommandTests = []struct {
535+
sshUserAuth string
536+
wantCmd string
537+
wantComment string
538+
logs []string
539+
}{
540+
{
541+
// without SSH_USER_AUTH
542+
"",
543+
"",
544+
" ",
545+
[]string{},
546+
}, {
547+
// with a wrong SSH_USER_AUTH
548+
"_non_existing_file_",
549+
"",
550+
" ",
551+
[]string{
552+
"open _non_existing_file_: no such file or directory",
553+
},
554+
}, {
555+
// SSH_USER_AUTH is not a publickey
556+
"../../test/sshUserAuthPassword",
557+
"",
558+
" ",
559+
[]string{},
560+
}, {
561+
// error in the publickey
562+
"../../test/sshUserAuthInvalidKey",
563+
"",
564+
" ",
565+
[]string{
566+
"ssh: no key found",
567+
},
568+
}, {
569+
// publickey is not a certificate
570+
"../../test/sshUserAuthKey",
571+
"",
572+
" ",
573+
[]string{},
574+
}, {
575+
// publickey is a certificate without force-command
576+
"../../test/sshUserAuthCert",
577+
"",
578+
" ",
579+
[]string{},
580+
}, {
581+
// publickey is a certificate with force-command
582+
"../../test/sshUserAuthCertForceCmd",
583+
"test-command",
584+
" (forced) ",
585+
[]string{},
586+
},
587+
}
588+
589+
func TestGetOriginalCommand(t *testing.T) {
590+
for _, tt := range getOriginalCommandTests {
591+
logBackend := setTestLogBackend()
592+
os.Setenv("SSH_USER_AUTH", tt.sshUserAuth)
593+
originalCmd, comment := getOriginalCommand()
594+
if originalCmd != tt.wantCmd || comment != tt.wantComment {
595+
t.Errorf("want '%s' - '%s', got '%s' - '%s'", tt.wantCmd, tt.wantComment, originalCmd, comment)
596+
}
597+
logs := getTestLogs(logBackend)
598+
if !reflect.DeepEqual(tt.logs, logs) {
599+
t.Errorf("want %v, got %v", tt.logs, logs)
600+
}
601+
}
602+
os.Unsetenv("SSH_USER_AUTH")
603+
}
604+
605+
func BenchmarkGetOriginalCommand(b *testing.B) {
606+
for _, tt := range getOriginalCommandTests {
607+
os.Setenv("SSH_USER_AUTH", tt.sshUserAuth)
608+
b.Run(tt.sshUserAuth, func(b *testing.B) {
609+
for i := 0; i < b.N; i++ {
610+
getOriginalCommand()
611+
}
612+
})
613+
}
614+
os.Unsetenv("SSH_USER_AUTH")
615+
}

doc/sshproxy.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ DESCRIPTION
1818

1919
'sshproxy' is used on a gateway to proxy SSH connections.
2020

21+
If the SSH server sets the 'SSH_USER_AUTH' environment variable (see
22+
'ExposeAuthInfo' in openssh's 'sshd_config'), 'sshproxy' uses it to get the
23+
'force-command' directive of the client's signed ssh public key. If a
24+
'force-command' is found, it is used as an argement to the 'ssh' command
25+
forked by sshproxy. Otherwise, the 'SSH_ORIGINAL_COMMAND' environment variable
26+
is used as the argument to the 'ssh' command.
27+
2128
OPTIONS
2229
-------
2330

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
go.etcd.io/etcd/api/v3 v3.6.5
1515
go.etcd.io/etcd/client/v3 v3.6.5
1616
go.uber.org/zap v1.27.0
17+
golang.org/x/crypto v0.43.0
1718
google.golang.org/grpc v1.76.0
1819
gopkg.in/yaml.v2 v2.4.0
1920
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
9696
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
9797
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
9898
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
99+
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
100+
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
99101
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
100102
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
101103
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -114,6 +116,8 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
114116
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
115117
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
116118
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
119+
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
120+
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
117121
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
118122
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
119123
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=

test/sshUserAuthCert

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
publickey [email protected] AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIJnb9PbGfwXbvXhNgOZsbqAo5SGjbcBgnU6DFQ6bTO44AAAAINQMu6aptjzBfNLiyi0y2Y1s1Qfdvuj9KVTqbIAN/cLYAAAAAAAAAAAAAAABAAAAC2Zvb0BiYXIuY29tAAAAAAAAAAAAAAAA//////////8AAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAAAMwAAAAtzc2gtZWQyNTUxOQAAACAdwC2KCieJ4+hiUnZT/SypRFmbjmn+vEgWpWGLfPVUHAAAAFMAAAALc3NoLWVkMjU1MTkAAABAB77ZKSV75Df+l5GH4Sf/6rOm6l4Rye9lTQaAytpAnWmNgFKpxBgvBOO2WEQBqbktqGJ2NNF6vp5Qct7wCbLZCA==

test/sshUserAuthCertForceCmd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
publickey [email protected] AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIL6frxCB6M1cFbFQqc0TGFo54k3VczrFy6R313RRp+uOAAAAINQMu6aptjzBfNLiyi0y2Y1s1Qfdvuj9KVTqbIAN/cLYAAAAAAAAAAAAAAABAAAAC2Zvb0BiYXIuY29tAAAAAAAAAAAAAAAA//////////8AAAAlAAAADWZvcmNlLWNvbW1hbmQAAAAQAAAADHRlc3QtY29tbWFuZAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAgHcAtigoniePoYlJ2U/0sqURZm45p/rxIFqVhi3z1VBwAAABTAAAAC3NzaC1lZDI1NTE5AAAAQMYvMM55rY8KFX67xrlhL0oXHHREEOo0Gp02luTiaekeo2IntQUEeYZb/T4g6eFJtf8A1XCS99FgXZ4/2UVcPQo=

test/sshUserAuthInvalidKey

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
publickey invalid_data

test/sshUserAuthKey

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
publickey ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINQMu6aptjzBfNLiyi0y2Y1s1Qfdvuj9KVTqbIAN/cLY

test/sshUserAuthPassword

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
password

0 commit comments

Comments
 (0)