347 lines
9.2 KiB
Go
347 lines
9.2 KiB
Go
package sshproxy
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/pem"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"strconv"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"agentdb/internal/model"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
func testSigner(t *testing.T) (string, ssh.Signer) {
|
|
t.Helper()
|
|
key, err := GenerateHostKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := ssh.ParsePrivateKey([]byte(key))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return key, s
|
|
}
|
|
func testTarget(t *testing.T) (string, ssh.Signer, *atomic.Int32) {
|
|
t.Helper()
|
|
_, signer := testSigner(t)
|
|
calls := &atomic.Int32{}
|
|
cfg := &ssh.ServerConfig{PasswordCallback: func(c ssh.ConnMetadata, b []byte) (*ssh.Permissions, error) {
|
|
calls.Add(1)
|
|
if c.User() == "target" && string(b) == "secret" {
|
|
return nil, nil
|
|
}
|
|
return nil, errors.New("denied")
|
|
}}
|
|
cfg.AddHostKey(signer)
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { l.Close() })
|
|
go func() {
|
|
for {
|
|
raw, e := l.Accept()
|
|
if e != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
defer raw.Close()
|
|
c, chs, reqs, e := ssh.NewServerConn(raw, cfg)
|
|
if e != nil {
|
|
return
|
|
}
|
|
defer c.Close()
|
|
go ssh.DiscardRequests(reqs)
|
|
for nc := range chs {
|
|
ch, rs, e := nc.Accept()
|
|
if e != nil {
|
|
continue
|
|
}
|
|
go func() {
|
|
defer ch.Close()
|
|
for r := range rs {
|
|
if r.Type == "exec" {
|
|
r.Reply(true, nil)
|
|
io.WriteString(ch, "hello\n")
|
|
io.WriteString(ch.Stderr(), "warning\n")
|
|
ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{7}))
|
|
return
|
|
}
|
|
if r.Type == "subsystem" {
|
|
r.Reply(true, nil)
|
|
io.Copy(ch, ch)
|
|
return
|
|
}
|
|
r.Reply(true, nil)
|
|
}
|
|
}()
|
|
}
|
|
}()
|
|
}
|
|
}()
|
|
return l.Addr().String(), signer, calls
|
|
}
|
|
func testProxy(t *testing.T, address string, signer ssh.Signer) model.Proxy {
|
|
key, _ := testSigner(t)
|
|
host, port, _ := net.SplitHostPort(address)
|
|
n, _ := strconv.Atoi(port)
|
|
return model.Proxy{GatewayUser: "gateway", GatewayPassword: "gateway-secret", TargetHost: host, TargetPort: n, TargetUser: "target", TargetPassword: "secret", SSH: model.SSHConfig{HostKey: key, TargetHostKey: string(ssh.MarshalAuthorizedKey(signer.PublicKey())), AuthMethod: "password"}}
|
|
}
|
|
func direct(ctx context.Context, n, a string) (net.Conn, error) {
|
|
return (&net.Dialer{}).DialContext(ctx, n, a)
|
|
}
|
|
|
|
func TestProbeDoesNotAuthenticateAndPinIsEnforced(t *testing.T) {
|
|
address, signer, calls := testTarget(t)
|
|
p := testProxy(t, address, signer)
|
|
result, err := Probe(context.Background(), p, direct)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls.Load() != 0 {
|
|
t.Fatal("probe sent credentials")
|
|
}
|
|
if result.Fingerprint != ssh.FingerprintSHA256(signer.PublicKey()) || result.HostKey != p.SSH.TargetHostKey {
|
|
t.Fatal("wrong probe identity")
|
|
}
|
|
if _, err = New().Test(context.Background(), p, direct); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, other := testSigner(t)
|
|
p.SSH.TargetHostKey = string(ssh.MarshalAuthorizedKey(other.PublicKey()))
|
|
before := calls.Load()
|
|
if _, err = New().Test(context.Background(), p, direct); err == nil {
|
|
t.Fatal("host key mismatch accepted")
|
|
}
|
|
if calls.Load() != before {
|
|
t.Fatal("sent credentials to mismatched host")
|
|
}
|
|
}
|
|
|
|
func startGateway(t *testing.T, p model.Proxy) (string, context.CancelFunc) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { cancel(); l.Close() })
|
|
go func() {
|
|
for {
|
|
c, err := l.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go New().Serve(ctx, c, p, direct, nil, nil)
|
|
}
|
|
}()
|
|
return l.Addr().String(), cancel
|
|
}
|
|
func TestGatewayExecSFTPAndRejectedForwarding(t *testing.T) {
|
|
address, signer, _ := testTarget(t)
|
|
p := testProxy(t, address, signer)
|
|
gateway, cancel := startGateway(t, p)
|
|
gatewayKey, err := ssh.ParsePrivateKey([]byte(p.SSH.HostKey))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := ssh.Dial("tcp", gateway, &ssh.ClientConfig{User: p.GatewayUser, Auth: []ssh.AuthMethod{ssh.Password(p.GatewayPassword)}, HostKeyCallback: ssh.FixedHostKey(gatewayKey.PublicKey()), Timeout: 5 * time.Second})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer c.Close()
|
|
session, err := c.NewSession()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = session.Setenv("LD_PRELOAD", "bad"); err == nil {
|
|
t.Fatal("unsafe environment accepted")
|
|
}
|
|
if err = session.Setenv("LANG", "en_US.UTF-8"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = session.RequestPty("xterm", 24, 80, ssh.TerminalModes{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = session.WindowChange(40, 120); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := session.CombinedOutput("ignored")
|
|
var exit *ssh.ExitError
|
|
if !errors.As(err, &exit) || exit.ExitStatus() != 7 {
|
|
t.Fatalf("exit status: %v", err)
|
|
}
|
|
if len(out) != 14 {
|
|
t.Fatalf("lost output: %q", out)
|
|
}
|
|
session.Close()
|
|
if ch, _, err := c.OpenChannel("direct-tcpip", nil); err == nil {
|
|
ch.Close()
|
|
t.Fatal("forwarding accepted")
|
|
}
|
|
if ok, _, err := c.SendRequest("tcpip-forward", true, nil); err != nil || ok {
|
|
t.Fatalf("global forwarding: %v %v", ok, err)
|
|
}
|
|
ch, reqs, err := c.OpenChannel("session", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go ssh.DiscardRequests(reqs)
|
|
ok, err := ch.SendRequest("subsystem", true, ssh.Marshal(struct{ Name string }{"sftp"}))
|
|
if err != nil || !ok {
|
|
t.Fatalf("sftp: %v", err)
|
|
}
|
|
payload := []byte("sftp bytes")
|
|
if _, err = ch.Write(payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := make([]byte, len(payload))
|
|
if _, err = io.ReadFull(ch, got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != string(payload) {
|
|
t.Fatal("subsystem payload changed")
|
|
}
|
|
ch.Close()
|
|
cancel()
|
|
done := make(chan error, 1)
|
|
go func() { done <- c.Wait() }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("cancel did not close gateway")
|
|
}
|
|
}
|
|
|
|
func TestGatewayPublicKeyAndSessionLimit(t *testing.T) {
|
|
address, signer, _ := testTarget(t)
|
|
p := testProxy(t, address, signer)
|
|
_, clientKey := testSigner(t)
|
|
p.GatewayPassword = ""
|
|
p.SSH.AuthorizedKeys = []string{string(ssh.MarshalAuthorizedKey(clientKey.PublicKey()))}
|
|
gateway, _ := startGateway(t, p)
|
|
host, _ := ssh.ParsePrivateKey([]byte(p.SSH.HostKey))
|
|
c, err := ssh.Dial("tcp", gateway, &ssh.ClientConfig{User: p.GatewayUser, Auth: []ssh.AuthMethod{ssh.PublicKeys(clientKey)}, HostKeyCallback: ssh.FixedHostKey(host.PublicKey()), Timeout: 5 * time.Second})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer c.Close()
|
|
for i := 0; i < 10; i++ {
|
|
ch, reqs, err := c.OpenChannel("session", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ch.Close()
|
|
go ssh.DiscardRequests(reqs)
|
|
}
|
|
if ch, _, err := c.OpenChannel("session", nil); err == nil {
|
|
ch.Close()
|
|
t.Fatal("session limit bypassed")
|
|
}
|
|
}
|
|
|
|
func TestRequestPolicy(t *testing.T) {
|
|
for _, name := range []string{"auth-agent-req@openssh.com", "x11-req", "unknown"} {
|
|
if allowedRequest(&ssh.Request{Type: name}) {
|
|
t.Fatal(name)
|
|
}
|
|
}
|
|
for _, name := range []string{"TERM", "LANG", "LC_ALL"} {
|
|
if !allowedRequest(&ssh.Request{Type: "env", Payload: ssh.Marshal(struct{ Name, Value string }{name, "value"})}) {
|
|
t.Fatal(name)
|
|
}
|
|
}
|
|
if allowedRequest(&ssh.Request{Type: "subsystem", Payload: ssh.Marshal(struct{ Name string }{"other"})}) {
|
|
t.Fatal("other subsystem accepted")
|
|
}
|
|
if allowedRequest(&ssh.Request{Type: "env", Payload: []byte{0xff}}) {
|
|
t.Fatal("malformed env accepted")
|
|
}
|
|
}
|
|
|
|
func TestEncryptedPrivateKeyUpstream(t *testing.T) {
|
|
_, key, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signer, err := ssh.NewSignerFromKey(key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
block, err := ssh.MarshalPrivateKeyWithPassphrase(key, "test", []byte("passphrase"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, host := testSigner(t)
|
|
cfg := &ssh.ServerConfig{PublicKeyCallback: func(c ssh.ConnMetadata, k ssh.PublicKey) (*ssh.Permissions, error) {
|
|
if c.User() == "target" && string(k.Marshal()) == string(signer.PublicKey().Marshal()) {
|
|
return nil, nil
|
|
}
|
|
return nil, errors.New("denied")
|
|
}}
|
|
cfg.AddHostKey(host)
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer l.Close()
|
|
go func() {
|
|
raw, e := l.Accept()
|
|
if e != nil {
|
|
return
|
|
}
|
|
defer raw.Close()
|
|
c, chs, reqs, e := ssh.NewServerConn(raw, cfg)
|
|
if e != nil {
|
|
return
|
|
}
|
|
defer c.Close()
|
|
go ssh.DiscardRequests(reqs)
|
|
for ch := range chs {
|
|
ch.Reject(ssh.Prohibited, "test")
|
|
}
|
|
}()
|
|
p := testProxy(t, l.Addr().String(), host)
|
|
p.SSH.AuthMethod = "privatekey"
|
|
p.SSH.PrivateKey = string(pem.EncodeToMemory(block))
|
|
p.SSH.Passphrase = "passphrase"
|
|
if _, err := New().Test(context.Background(), p, direct); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p.SSH.Passphrase = "wrong"
|
|
if _, err := upstreamConfig(p); err == nil {
|
|
t.Fatal("bad passphrase accepted")
|
|
}
|
|
}
|
|
|
|
func TestCanceledHandshakeClosesConnection(t *testing.T) {
|
|
_, signer := testSigner(t)
|
|
p := testProxy(t, "127.0.0.1:22", signer)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
raw, peer := net.Pipe()
|
|
defer peer.Close()
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := New().Test(ctx, p, func(context.Context, string, string) (net.Conn, error) { return raw, nil })
|
|
done <- err
|
|
}()
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("canceled handshake succeeded")
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("handshake cancellation leaked")
|
|
}
|
|
}
|