CVE-2023-43809: Improper Authentication (CWE-287)
value: Soft Serve is a self-hostable Git server for the command line. Prior to version 0.6.2, a security vulnerability in Soft Serve could allow an unauthenticated, remote attacker to bypass public key authentication when keyboard-interactive SSH authentication is active, through the `allow-keyless` setting, and the public key requires additional client-side verification for example using FIDO2 or GPG. This is due to insufficient validation procedures of the public key step during SSH request handshake, granting unauthorized access if the keyboard-interaction mode is utilized. An attacker could exploit this vulnerability by presenting manipulated SSH requests using keyboard-interactive authentication mode. This could potentially result in unauthorized access to the Soft Serve. Users should upgrade to the latest Soft Serve version `v0.6.2` to receive the patch for this issue. To workaround this vulnerability without upgrading, users can temporarily disable Keyboard-Interactive SSH Authentication using the `allow-keyless` setting.
CWE description: When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
CVSS v3 base score: 7.5
Published: 2023-10-04T21:15Z
Repository: https://github.com/charmbracelet/soft-serve
Language: Go
Files changed: server/ssh/middleware.go, server/ssh/ssh.go
Commit date: 2023-09-27 07:47:24 -0700
Commit message: fix(ssh): add authentication middleware

We need to verify that the key used to establish the connection is the
same key used for authentication, otherwise, refuse connection.

Diff: diff --git a/server/ssh/middleware.go b/server/ssh/middleware.go
index 300dd37..23bf5ea 100644
--- a/server/ssh/middleware.go
+++ b/server/ssh/middleware.go
@@ -11,15 +11,49 @@ import (
 	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/charmbracelet/soft-serve/server/ssh/cmd"
 	"github.com/charmbracelet/soft-serve/server/sshutils"
 	"github.com/charmbracelet/soft-serve/server/store"
 	"github.com/charmbracelet/ssh"
+	"github.com/charmbracelet/wish"
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/promauto"
 	"github.com/spf13/cobra"
+	gossh "golang.org/x/crypto/ssh"
 )
 
+// ErrPermissionDenied is returned when a user is not allowed connect.
+var ErrPermissionDenied = fmt.Errorf("permission denied")
+
+// AuthenticationMiddleware handles authentication.
+func AuthenticationMiddleware(sh ssh.Handler) ssh.Handler {
+	return func(s ssh.Session) {
+		// XXX: The authentication key is set in the context but gossh doesn't
+		// validate the authentication. We need to verify that the _last_ key
+		// that was approved is the one that's being used.
+
+		pk := s.PublicKey()
+		if pk != nil {
+			// There is no public key stored in the context, public-key auth
+			// was never requested, skip
+			perms := s.Permissions().Permissions
+			if perms == nil {
+				wish.Fatalln(s, ErrPermissionDenied)
+				return
+			}
+
+			// Check if the key is the same as the one we have in context
+			fp := perms.Extensions["pubkey-fp"]
+			if fp != gossh.FingerprintSHA256(pk) {
+				wish.Fatalln(s, ErrPermissionDenied)
+				return
+			}
+		}
+
+		sh(s)
+	}
+}
+
 // ContextMiddleware adds the config, backend, and logger to the session context.
 func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler {
 	return func(sh ssh.Handler) ssh.Handler {
 		return func(s ssh.Session) {
 			s.Context().SetValue(sshutils.ContextKeySession, s)
diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go
index d02824d..4d57dc9 100644
--- a/server/ssh/ssh.go
+++ b/server/ssh/ssh.go
@@ -75,10 +75,15 @@ func NewSSHServer(ctx context.Context) (*SSHServer, error) {
 			CommandMiddleware,
 			// Logging middleware.
 			LoggingMiddleware,
 			// Context middleware.
 			ContextMiddleware(cfg, dbx, datastore, be, logger),
+			// Authentication middleware.
+			// gossh.PublicKeyHandler doesn't guarantee that the public key
+			// is in fact the one used for authentication, so we need to
+			// check it again here.
+			AuthenticationMiddleware,
 		),
 	}
 
 	s.srv, err = wish.NewServer(
 		ssh.PublicKeyAuth(s.PublicKeyHandler),
@@ -89,10 +94,20 @@ func NewSSHServer(ctx context.Context) (*SSHServer, error) {
 	)
 	if err != nil {
 		return nil, err
 	}
 
+	if config.IsDebug() {
+		s.srv.ServerConfigCallback = func(ctx ssh.Context) *gossh.ServerConfig {
+			return &gossh.ServerConfig{
+				AuthLogCallback: func(conn gossh.ConnMetadata, method string, err error) {
+					logger.Debug("authentication", "user", conn.User(), "method", method, "err", err)
+				},
+			}
+		}
+	}
+
 	if cfg.SSH.MaxTimeout > 0 {
 		s.srv.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second
 	}
 
 	if cfg.SSH.IdleTimeout > 0 {
@@ -128,10 +143,23 @@ func (s *SSHServer) Close() error {
 // Shutdown gracefully shuts down the SSH server.
 func (s *SSHServer) Shutdown(ctx context.Context) error {
 	return s.srv.Shutdown(ctx)
 }
 
+func initializePermissions(ctx ssh.Context) {
+	perms := ctx.Permissions()
+	if perms == nil || perms.Permissions == nil {
+		perms = &ssh.Permissions{Permissions: &gossh.Permissions{}}
+	}
+	if perms.Extensions == nil {
+		perms.Extensions = make(map[string]string)
+	}
+	if perms.Permissions.Extensions == nil {
+		perms.Permissions.Extensions = make(map[string]string)
+	}
+}
+
 // PublicKeyAuthHandler handles public key authentication.
 func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed bool) {
 	if pk == nil {
 		return false
 	}
@@ -142,17 +170,37 @@ func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed
 
 	user, _ := s.be.UserByPublicKey(ctx, pk)
 	if user != nil {
 		ctx.SetValue(proto.ContextKeyUser, user)
 		allowed = true
+
+		// XXX: store the first "approved" public-key fingerprint in the
+		// permissions block to use for authentication later.
+		initializePermissions(ctx)
+		perms := ctx.Permissions()
+
+		// Set the public key fingerprint to be used for authentication.
+		perms.Extensions["pubkey-fp"] = gossh.FingerprintSHA256(pk)
+		ctx.SetValue(ssh.ContextKeyPermissions, perms)
 	}
 
 	return
 }
 
 // KeyboardInteractiveHandler handles keyboard interactive authentication.
 // This is used after all public key authentication has failed.
 func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
 	ac := s.be.AllowKeyless(ctx)
 	keyboardInteractiveCounter.WithLabelValues(strconv.FormatBool(ac)).Inc()
+
+	// If we're allowing keyless access, reset the public key fingerprint
+	if ac {
+		initializePermissions(ctx)
+		perms := ctx.Permissions()
+
+		// XXX: reset the public-key fingerprint. This is used to validate the
+		// public key being used to authenticate.
+		perms.Extensions["pubkey-fp"] = ""
+		ctx.SetValue(ssh.ContextKeyPermissions, perms)
+	}
 	return ac
 }

Fixed code: 	"github.com/charmbracelet/wish"
	gossh "golang.org/x/crypto/ssh"
// ErrPermissionDenied is returned when a user is not allowed connect.
var ErrPermissionDenied = fmt.Errorf("permission denied")

// AuthenticationMiddleware handles authentication.
func AuthenticationMiddleware(sh ssh.Handler) ssh.Handler {
	return func(s ssh.Session) {
		// XXX: The authentication key is set in the context but gossh doesn't
		// validate the authentication. We need to verify that the _last_ key
		// that was approved is the one that's being used.

		pk := s.PublicKey()
		if pk != nil {
			// There is no public key stored in the context, public-key auth
			// was never requested, skip
			perms := s.Permissions().Permissions
			if perms == nil {
				wish.Fatalln(s, ErrPermissionDenied)
				return
			}

			// Check if the key is the same as the one we have in context
			fp := perms.Extensions["pubkey-fp"]
			if fp != gossh.FingerprintSHA256(pk) {
				wish.Fatalln(s, ErrPermissionDenied)
				return
			}
		}

		sh(s)
	}
}

			// Authentication middleware.
			// gossh.PublicKeyHandler doesn't guarantee that the public key
			// is in fact the one used for authentication, so we need to
			// check it again here.
			AuthenticationMiddleware,
	if config.IsDebug() {
		s.srv.ServerConfigCallback = func(ctx ssh.Context) *gossh.ServerConfig {
			return &gossh.ServerConfig{
				AuthLogCallback: func(conn gossh.ConnMetadata, method string, err error) {
					logger.Debug("authentication", "user", conn.User(), "method", method, "err", err)
				},
			}
		}
	}

func initializePermissions(ctx ssh.Context) {
	perms := ctx.Permissions()
	if perms == nil || perms.Permissions == nil {
		perms = &ssh.Permissions{Permissions: &gossh.Permissions{}}
	}
	if perms.Extensions == nil {
		perms.Extensions = make(map[string]string)
	}
	if perms.Permissions.Extensions == nil {
		perms.Permissions.Extensions = make(map[string]string)
	}
}


		// XXX: store the first "approved" public-key fingerprint in the
		// permissions block to use for authentication later.
		initializePermissions(ctx)
		perms := ctx.Permissions()

		// Set the public key fingerprint to be used for authentication.
		perms.Extensions["pubkey-fp"] = gossh.FingerprintSHA256(pk)
		ctx.SetValue(ssh.ContextKeyPermissions, perms)

	// If we're allowing keyless access, reset the public key fingerprint
	if ac {
		initializePermissions(ctx)
		perms := ctx.Permissions()

		// XXX: reset the public-key fingerprint. This is used to validate the
		// public key being used to authenticate.
		perms.Extensions["pubkey-fp"] = ""
		ctx.SetValue(ssh.ContextKeyPermissions, perms)
	}