1package ssh23import (4 "context"5 "net"6 "testing"78 "github.com/charmbracelet/keygen"9 "github.com/charmbracelet/soft-serve/pkg/backend"10 "github.com/charmbracelet/soft-serve/pkg/config"11 "github.com/charmbracelet/soft-serve/pkg/db"12 "github.com/charmbracelet/soft-serve/pkg/db/migrate"13 "github.com/charmbracelet/soft-serve/pkg/proto"14 "github.com/charmbracelet/soft-serve/pkg/store"15 "github.com/charmbracelet/soft-serve/pkg/store/database"16 "github.com/charmbracelet/ssh"17 "github.com/matryer/is"18 gossh "golang.org/x/crypto/ssh"19 _ "modernc.org/sqlite"20)2122// TestAuthenticationBypass tests for CVE-TBD: Authentication Bypass Vulnerability23//24// VULNERABILITY:25// A critical authentication bypass allows an attacker to impersonate any user26// (including Admin) by "offering" the victim's public key during the SSH handshake27// before authenticating with their own valid key. This occurs because the user28// identity is stored in the session context during the "offer" phase in29// PublicKeyHandler and is not properly cleared/validated in AuthenticationMiddleware.30//31// This test verifies that:32// 1. User context is properly set based on the AUTHENTICATED key, not offered keys33// 2. User context from failed authentication attempts is not preserved34// 3. Non-admin users cannot gain admin privileges through this attack35func TestAuthenticationBypass(t *testing.T) {36 is := is.New(t)37 ctx := context.Background()3839 // Setup temporary database40 dp := t.TempDir()41 cfg := config.DefaultConfig()42 cfg.DataPath = dp43 cfg.DB.Driver = "sqlite"44 cfg.DB.DataSource = dp + "/test.db"4546 ctx = config.WithContext(ctx, cfg)47 dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)48 is.NoErr(err)49 defer dbx.Close()5051 is.NoErr(migrate.Migrate(ctx, dbx))52 dbstore := database.New(ctx, dbx)53 ctx = store.WithContext(ctx, dbstore)54 be := backend.New(ctx, cfg, dbx, dbstore)55 ctx = backend.WithContext(ctx, be)5657 // Generate keys for admin and attacker58 adminKeyPath := dp + "/admin_key"59 adminPair, err := keygen.New(adminKeyPath, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite())60 is.NoErr(err)6162 attackerKeyPath := dp + "/attacker_key"63 attackerPair, err := keygen.New(attackerKeyPath, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite())64 is.NoErr(err)6566 // Parse public keys67 adminPubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(adminPair.AuthorizedKey()))68 is.NoErr(err)6970 attackerPubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(attackerPair.AuthorizedKey()))71 is.NoErr(err)7273 // Create admin user74 adminUser, err := be.CreateUser(ctx, "testadmin", proto.UserOptions{75 Admin: true,76 PublicKeys: []gossh.PublicKey{adminPubKey},77 })78 is.NoErr(err)79 is.True(adminUser != nil)8081 // Create attacker (non-admin) user82 attackerUser, err := be.CreateUser(ctx, "testattacker", proto.UserOptions{83 Admin: false,84 PublicKeys: []gossh.PublicKey{attackerPubKey},85 })86 is.NoErr(err)87 is.True(attackerUser != nil)88 is.True(!attackerUser.IsAdmin()) // Verify attacker is NOT admin8990 // Test: Verify that looking up user by key gives correct user91 t.Run("user_lookup_by_key", func(t *testing.T) {92 is := is.New(t)9394 // Looking up admin key should return admin user95 user, err := be.UserByPublicKey(ctx, adminPubKey)96 is.NoErr(err)97 is.Equal(user.Username(), "testadmin")98 is.True(user.IsAdmin())99100 // Looking up attacker key should return attacker user101 user, err = be.UserByPublicKey(ctx, attackerPubKey)102 is.NoErr(err)103 is.Equal(user.Username(), "testattacker")104 is.True(!user.IsAdmin())105 })106107 // Test: Simulate the authentication bypass vulnerability108 // This test documents the EXPECTED behavior to prevent regression109 t.Run("authentication_bypass_simulation", func(t *testing.T) {110 is := is.New(t)111112 // Create a mock context113 mockCtx := &mockSSHContext{114 Context: ctx,115 values: make(map[any]any),116 permissions: &ssh.Permissions{Permissions: &gossh.Permissions{Extensions: make(map[string]string)}},117 }118119 // ATTACK SIMULATION:120 // Step 1: SSH client offers admin's public key121 // PublicKeyHandler is called and sets admin user in context122 mockCtx.SetValue(proto.ContextKeyUser, adminUser)123 mockCtx.permissions.Extensions["pubkey-fp"] = gossh.FingerprintSHA256(adminPubKey)124125 // Step 2: Signature verification FAILS (attacker doesn't have admin's private key)126 // SSH protocol continues to next key...127128 // Step 3: SSH client offers attacker's key (which SUCCEEDS)129 // PublicKeyHandler is called again, fingerprint is updated130 mockCtx.permissions.Extensions["pubkey-fp"] = gossh.FingerprintSHA256(attackerPubKey)131 // BUG: Admin user is STILL in context from step 1!132133 // Step 4: AuthenticationMiddleware should re-lookup user based on authenticated key134 // The middleware MUST NOT trust the user already in context135 authenticatedUser, err := be.UserByPublicKey(mockCtx, attackerPubKey)136 is.NoErr(err)137138 // EXPECTED: User should be "attacker", NOT "admin"139 is.Equal(authenticatedUser.Username(), "testattacker")140 is.True(!authenticatedUser.IsAdmin())141142 // If the vulnerability exists, the context would still have admin user143 contextUser := proto.UserFromContext(mockCtx)144 if contextUser != nil && contextUser.Username() == "testadmin" {145 t.Logf("WARNING: Context still contains admin user! This indicates the vulnerability exists.")146 t.Logf("The authenticated key is attacker's, but context has admin user.")147 }148 })149}150151// mockSSHContext implements ssh.Context for testing152type mockSSHContext struct {153 context.Context154 values map[any]any155 permissions *ssh.Permissions156}157158func (m *mockSSHContext) SetValue(key, value any) {159 m.values[key] = value160}161162func (m *mockSSHContext) Value(key any) any {163 if v, ok := m.values[key]; ok {164 return v165 }166 return m.Context.Value(key)167}168169func (m *mockSSHContext) Permissions() *ssh.Permissions {170 return m.permissions171}172173func (m *mockSSHContext) User() string { return "" }174func (m *mockSSHContext) RemoteAddr() net.Addr { return &net.TCPAddr{} }175func (m *mockSSHContext) LocalAddr() net.Addr { return &net.TCPAddr{} }176func (m *mockSSHContext) ServerVersion() string { return "" }177func (m *mockSSHContext) ClientVersion() string { return "" }178func (m *mockSSHContext) SessionID() string { return "" }179func (m *mockSSHContext) Lock() {}180func (m *mockSSHContext) Unlock() {}