From 18aa1930398d32222803419a6089d12f87138fa0 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Tue, 2 Jun 2026 12:49:24 +0100 Subject: [PATCH] Tightening security --- frontend/package.json.md5 | 2 +- frontend/src/js/secrets.js | 6 +- go.sum | 4 -- internal/sessions/manager.go | 98 ++++++++++++++++++++++--------- internal/sessions/manager_test.go | 37 ++++++++++++ internal/sshclient/sshclient.go | 17 ++++-- 6 files changed, 125 insertions(+), 39 deletions(-) diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 26e4420..58a75ed 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -99c6bbb0236daee684251a5f62bbd974 \ No newline at end of file +0eb34f66d9120fafc023eb90e9144592 \ No newline at end of file diff --git a/frontend/src/js/secrets.js b/frontend/src/js/secrets.js index d6ee68a..ef65b26 100644 --- a/frontend/src/js/secrets.js +++ b/frontend/src/js/secrets.js @@ -280,7 +280,11 @@ class SecretPrompt { document.body.appendChild(overlay); this._overlay = overlay; - acceptBtn.focus(); + requestAnimationFrame(() => { + overlay.classList.add('is-visible'); + modal.classList.add('is-visible'); + acceptBtn.focus(); + }); acceptBtn.addEventListener('click', () => { this._close(); diff --git a/go.sum b/go.sum index d413a52..767d20c 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,6 @@ github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozH github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -93,8 +91,6 @@ golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index b57269c..85fec86 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -57,14 +57,17 @@ type Session struct { // It coordinates between protocol clients, the storage layer, // the secret store, and the Wails runtime for event emission. type Manager struct { - mu sync.RWMutex - sessions map[string]*Session - store *storage.Store - secretStore *secrets.SecretStore - keychainStore *secrets.KeychainStore - auditLog *audit.Logger - recordStore *recording.Store - ctx context.Context // Wails app context for event emission + mu sync.RWMutex + sessions map[string]*Session + store *storage.Store + secretStore *secrets.SecretStore + keychainStore *secrets.KeychainStore + auditLog *audit.Logger + recordStore *recording.Store + ctx context.Context // Wails app context for event emission + hostKeyMu sync.Mutex + hostKeyChans map[string]chan bool + hostKeyListenerRegistered bool } // NewManager creates a Manager wired to the given backing services. @@ -82,6 +85,7 @@ func NewManager(store *storage.Store, secretStore *secrets.SecretStore, keychain // SetContext stores the Wails runtime context for event emission. func (m *Manager) SetContext(ctx context.Context) { m.ctx = ctx + m.registerHostKeyResponseListener() } // Connect creates a new session from a profile and connects it. @@ -1465,25 +1469,18 @@ func (m *Manager) promptHostKey(sessionID string, host string, keyType string, f requestID := uuid.New().String() ch := make(chan bool, 1) - cancel := wailsRuntime.EventsOn(m.ctx, "session:hostkey:response", func(optionalData ...interface{}) { - if len(optionalData) == 0 { - return - } - data, ok := optionalData[0].(map[string]interface{}) - if !ok { - return - } - rid, _ := data["requestID"].(string) - if rid != requestID { - return - } - accepted, _ := data["accepted"].(bool) - select { - case ch <- accepted: - default: - } - }) - defer cancel() + + m.hostKeyMu.Lock() + if m.hostKeyChans == nil { + m.hostKeyChans = make(map[string]chan bool) + } + m.hostKeyChans[requestID] = ch + m.hostKeyMu.Unlock() + defer func() { + m.hostKeyMu.Lock() + delete(m.hostKeyChans, requestID) + m.hostKeyMu.Unlock() + }() m.emitEvent("session:hostkey:verify", map[string]interface{}{ "requestID": requestID, @@ -1496,8 +1493,51 @@ func (m *Manager) promptHostKey(sessionID string, host string, keyType string, f select { case accepted := <-ch: return accepted, nil - case <-time.After(5 * time.Minute): - return false, fmt.Errorf("host key prompt timed out") + case <-time.After(60 * time.Second): + return false, fmt.Errorf("host key prompt timed out waiting for user response") + } +} + +func (m *Manager) registerHostKeyResponseListener() { + ctx := m.ctx + if ctx == nil { + return + } + + m.hostKeyMu.Lock() + if m.hostKeyChans == nil { + m.hostKeyChans = make(map[string]chan bool) + } + if m.hostKeyListenerRegistered { + m.hostKeyMu.Unlock() + return + } + m.hostKeyListenerRegistered = true + m.hostKeyMu.Unlock() + + wailsRuntime.EventsOn(ctx, "session:hostkey:response", m.handleHostKeyResponse) +} + +func (m *Manager) handleHostKeyResponse(optionalData ...interface{}) { + if len(optionalData) == 0 { + return + } + data, ok := optionalData[0].(map[string]interface{}) + if !ok { + return + } + requestID, _ := data["requestID"].(string) + accepted, _ := data["accepted"].(bool) + + m.hostKeyMu.Lock() + ch, exists := m.hostKeyChans[requestID] + m.hostKeyMu.Unlock() + + if exists { + select { + case ch <- accepted: + default: + } } } diff --git a/internal/sessions/manager_test.go b/internal/sessions/manager_test.go index ece51ad..de0116c 100644 --- a/internal/sessions/manager_test.go +++ b/internal/sessions/manager_test.go @@ -189,6 +189,43 @@ func TestHostKeyCallbackStrictRejectsUnknownHost(t *testing.T) { } } +func TestHandleHostKeyResponseRoutesByRequestID(t *testing.T) { + mgr := NewManager(nil, nil, nil, nil, nil) + ch := make(chan bool, 1) + mgr.hostKeyChans = map[string]chan bool{"request-1": ch} + + mgr.handleHostKeyResponse(map[string]interface{}{ + "requestID": "request-1", + "accepted": true, + }) + + select { + case accepted := <-ch: + if !accepted { + t.Fatal("accepted = false, want true") + } + default: + t.Fatal("expected host key response to be routed") + } +} + +func TestHandleHostKeyResponseIgnoresOtherRequestIDs(t *testing.T) { + mgr := NewManager(nil, nil, nil, nil, nil) + ch := make(chan bool, 1) + mgr.hostKeyChans = map[string]chan bool{"request-1": ch} + + mgr.handleHostKeyResponse(map[string]interface{}{ + "requestID": "request-2", + "accepted": true, + }) + + select { + case <-ch: + t.Fatal("unexpected host key response routed") + default: + } +} + func testPublicKey(t *testing.T) ssh.PublicKey { t.Helper() pub, _, err := ed25519.GenerateKey(rand.Reader) diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go index bba0f4f..03d5fc2 100644 --- a/internal/sshclient/sshclient.go +++ b/internal/sshclient/sshclient.go @@ -167,10 +167,9 @@ func (c *Client) connectInternal( sessionEnv := terminalenv.SSHEnvVars(sc.EnvVars) for _, key := range terminalenv.SortedEnvKeys(sessionEnv) { - // Servers may reject env requests unless AcceptEnv permits them. Locale - // requests improve Unicode input when accepted, but rejection should not - // prevent the terminal session from starting. - _ = session.Setenv(key, sessionEnv[key]) + // Some SSH servers never reply to env requests. Send them without + // requiring a reply so optional locale/profile env cannot stall login. + sendSessionEnv(session, key, sessionEnv[key]) } if err := session.RequestPty(termType, rows, cols, modes); err != nil { @@ -244,6 +243,16 @@ func (c *Client) connectInternal( return nil } +func sendSessionEnv(session *ssh.Session, name, value string) { + _, _ = session.SendRequest("env", false, ssh.Marshal(struct { + Name string + Value string + }{ + Name: name, + Value: value, + })) +} + // Write sends data to the SSH session's stdin. func (c *Client) Write(data []byte) error { c.mu.Lock()