fix: harden SSH trust and dependency security

This commit is contained in:
Andy Dixon
2026-06-02 12:25:20 +01:00
parent fb230ee35d
commit ea6a9743d1
17 changed files with 1365 additions and 186 deletions
+53 -32
View File
@@ -680,6 +680,9 @@ func (s *Service) ExportProfiles(includeSecrets bool) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to list profiles for export: %w", err)
}
if !includeSecrets {
profiles = redactProfileSecrets(profiles)
}
data, err := json.MarshalIndent(profiles, "", " ")
if err != nil {
@@ -1604,28 +1607,17 @@ func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
// GenerateSSHKey generates a new SSH key pair.
func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
// Sanitise name — only allow alphanumeric, hyphens, underscores
for _, ch := range name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
return fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
}
}
home, err := os.UserHomeDir()
keyPath, _, err := sshKeyPairPaths(name)
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
return err
}
sshDir := filepath.Join(home, ".ssh")
sshDir := filepath.Dir(keyPath)
// Ensure .ssh directory exists
if err := os.MkdirAll(sshDir, 0700); err != nil {
return fmt.Errorf("unable to create .ssh directory: %w", err)
}
keyPath := filepath.Join(sshDir, name)
// Check if key already exists
if _, err := os.Stat(keyPath); err == nil {
return fmt.Errorf("key %q already exists", name)
@@ -1659,11 +1651,10 @@ func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphra
// GetSSHPublicKey returns the content of a public key file.
func (s *Service) GetSSHPublicKey(name string) (string, error) {
home, err := os.UserHomeDir()
_, pubPath, err := sshKeyPairPaths(name)
if err != nil {
return "", fmt.Errorf("unable to determine home directory: %w", err)
return "", err
}
pubPath := filepath.Join(home, ".ssh", name+".pub")
data, err := os.ReadFile(pubPath)
if err != nil {
return "", fmt.Errorf("unable to read public key: %w", err)
@@ -1673,22 +1664,9 @@ func (s *Service) GetSSHPublicKey(name string) (string, error) {
// DeleteSSHKey removes an SSH key pair.
func (s *Service) DeleteSSHKey(name string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
home, err := os.UserHomeDir()
privPath, pubPath, err := sshKeyPairPaths(name)
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
privPath := filepath.Join(sshDir, name)
pubPath := privPath + ".pub"
// Verify paths are within .ssh directory (prevent directory traversal)
absPriv, _ := filepath.Abs(privPath)
absSsh, _ := filepath.Abs(sshDir)
if !strings.HasPrefix(absPriv, absSsh+string(os.PathSeparator)) {
return fmt.Errorf("invalid key name")
return err
}
var errs []string
@@ -1703,3 +1681,46 @@ func (s *Service) DeleteSSHKey(name string) error {
}
return nil
}
func sshKeyPairPaths(name string) (string, string, error) {
keyName, err := validateSSHKeyName(name)
if err != nil {
return "", "", err
}
home, err := os.UserHomeDir()
if err != nil {
return "", "", fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
privPath := filepath.Join(sshDir, keyName)
pubPath := privPath + ".pub"
return privPath, pubPath, nil
}
func validateSSHKeyName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("key name is required")
}
for _, ch := range name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
return "", fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
}
}
return name, nil
}
func redactProfileSecrets(profiles []*models.Profile) []*models.Profile {
redacted := make([]*models.Profile, len(profiles))
for i, profile := range profiles {
cp := *profile
if cp.SSH != nil {
sshCfg := *cp.SSH
sshCfg.ProxyPassword = ""
sshCfg.TOTPSecret = ""
cp.SSH = &sshCfg
}
redacted[i] = &cp
}
return redacted
}
+67
View File
@@ -0,0 +1,67 @@
package api
import (
"path/filepath"
"strings"
"testing"
"github.com/andydixon/nexus/internal/models"
)
func TestSSHKeyPairPathsAcceptsSafeName(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
privPath, pubPath, err := sshKeyPairPaths("deploy_key-01")
if err != nil {
t.Fatalf("sshKeyPairPaths() error = %v", err)
}
wantPriv := filepath.Join(home, ".ssh", "deploy_key-01")
if privPath != wantPriv {
t.Fatalf("private path = %q, want %q", privPath, wantPriv)
}
if pubPath != wantPriv+".pub" {
t.Fatalf("public path = %q, want %q", pubPath, wantPriv+".pub")
}
}
func TestSSHKeyPairPathsRejectsTraversal(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("../outside")
if err == nil {
t.Fatal("expected traversal key name to be rejected")
}
if !strings.Contains(err.Error(), "invalid characters") {
t.Fatalf("error = %q, want invalid characters", err.Error())
}
}
func TestSSHKeyPairPathsRejectsNestedPath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("nested/key")
if err == nil {
t.Fatal("expected nested key path to be rejected")
}
}
func TestRedactProfileSecretsClearsInlineSSHSecrets(t *testing.T) {
profile := models.NewProfile(models.ProtocolSSH)
profile.SSH.ProxyPassword = "proxy-secret"
profile.SSH.TOTPSecret = "totp-secret"
got := redactProfileSecrets([]*models.Profile{&profile})
if got[0].SSH.ProxyPassword != "" {
t.Fatalf("ProxyPassword = %q, want empty", got[0].SSH.ProxyPassword)
}
if got[0].SSH.TOTPSecret != "" {
t.Fatalf("TOTPSecret = %q, want empty", got[0].SSH.TOTPSecret)
}
if profile.SSH.ProxyPassword == "" || profile.SSH.TOTPSecret == "" {
t.Fatal("redaction mutated the original profile")
}
}
+61 -5
View File
@@ -15,6 +15,7 @@ import (
"github.com/google/uuid"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/crypto/ssh"
"github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/githubclient"
@@ -653,7 +654,7 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
}
}
if err := client.Connect(getPassword, getPassphrase); err != nil {
if err := client.ConnectWithHostKeyCallback(m.hostKeyCallback(sess.Info.ID, sshConf.HostKeyPolicy), getPassword, getPassphrase); err != nil {
return err
}
@@ -1012,6 +1013,7 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
cfg.SSHUsername = vncConf.SSHUsername
cfg.SSHAuthMethod = vncConf.SSHAuthMethod
cfg.SSHAgentAuth = vncConf.SSHAgentAuth
cfg.SSHHostKeyCallback = m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk)
// Resolve SSH credentials
switch vncConf.SSHAuthMethod {
@@ -1445,6 +1447,60 @@ func (m *Manager) PingSession(sessionID string) (int64, error) {
// Internal: helpers
// ---------------------------------------------------------------------------
func (m *Manager) hostKeyCallback(sessionID string, policy models.HostKeyPolicy) ssh.HostKeyCallback {
if policy == "" {
policy = models.HostKeyAsk
}
verifier := sshclient.NewHostKeyVerifier(m.store, policy)
verifier.SetPromptHandler(func(host string, keyType string, fingerprint string) (bool, error) {
return m.promptHostKey(sessionID, host, keyType, fingerprint)
})
return verifier.HostKeyCallback()
}
func (m *Manager) promptHostKey(sessionID string, host string, keyType string, fingerprint string) (bool, error) {
if m.ctx == nil {
return false, fmt.Errorf("host key prompt unavailable")
}
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.emitEvent("session:hostkey:verify", map[string]interface{}{
"requestID": requestID,
"sessionID": sessionID,
"host": host,
"keyType": keyType,
"fingerprint": fingerprint,
})
select {
case accepted := <-ch:
return accepted, nil
case <-time.After(5 * time.Minute):
return false, fmt.Errorf("host key prompt timed out")
}
}
// buildSecretCallback returns a func() (string, error) that resolves a
// credential according to the profile's SecretMode:
// - "saved": check keychain/vault first, prompt if missing, then persist
@@ -1806,11 +1862,11 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
}
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionReconnecting),
"attempt": attempt,
"sessionID": sessionID,
"state": string(models.SessionReconnecting),
"attempt": attempt,
"maxRetries": maxTries,
"delaySec": delaySec,
"delaySec": delaySec,
})
go func() {
+63
View File
@@ -1,9 +1,14 @@
package sessions
import (
"crypto/ed25519"
"crypto/rand"
"path/filepath"
"testing"
"github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/storage"
"golang.org/x/crypto/ssh"
)
// ---------------------------------------------------------------------------
@@ -148,3 +153,61 @@ func TestCloseAllEmpty(t *testing.T) {
t.Error("expected 0 sessions after CloseAll")
}
}
func TestHostKeyCallbackAcceptNewStoresHost(t *testing.T) {
store := newSessionTestStore(t)
mgr := NewManager(store, nil, nil, nil, nil)
callback := mgr.hostKeyCallback("session-1", models.HostKeyAcceptNew)
key := testPublicKey(t)
if err := callback("jump.example:2222", nil, key); err != nil {
t.Fatalf("HostKeyCallback() error = %v", err)
}
saved, err := store.GetKnownHost("jump.example", 2222)
if err != nil {
t.Fatalf("GetKnownHost() error = %v", err)
}
if saved == nil {
t.Fatal("expected host key to be saved")
}
if saved.Host != "jump.example" {
t.Fatalf("saved host = %q, want jump.example", saved.Host)
}
if saved.Port != 2222 {
t.Fatalf("saved port = %d, want 2222", saved.Port)
}
}
func TestHostKeyCallbackStrictRejectsUnknownHost(t *testing.T) {
mgr := NewManager(newSessionTestStore(t), nil, nil, nil, nil)
callback := mgr.hostKeyCallback("session-1", models.HostKeyStrict)
err := callback("unknown.example:22", nil, testPublicKey(t))
if err == nil {
t.Fatal("expected strict policy to reject unknown host")
}
}
func testPublicKey(t *testing.T) ssh.PublicKey {
t.Helper()
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey() error = %v", err)
}
key, err := ssh.NewPublicKey(pub)
if err != nil {
t.Fatalf("NewPublicKey() error = %v", err)
}
return key
}
func newSessionTestStore(t *testing.T) *storage.Store {
t.Helper()
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
+10 -6
View File
@@ -596,12 +596,7 @@ func (c *Client) connectViaJumpHosts(sc models.SSHConfig, finalConfig *ssh.Clien
jumpAuth = append(jumpAuth, ssh.PublicKeys(signer))
}
jumpConfig := &ssh.ClientConfig{
User: jump.Username,
Auth: jumpAuth,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: finalConfig.Timeout,
}
jumpConfig := jumpHostClientConfig(jump, jumpAuth, finalConfig)
if currentClient == nil {
client, err := ssh.Dial("tcp", jumpAddr, jumpConfig)
@@ -648,6 +643,15 @@ func (c *Client) connectViaJumpHosts(sc models.SSHConfig, finalConfig *ssh.Clien
return ssh.NewClient(ncc, chans, reqs), nil
}
func jumpHostClientConfig(jump models.JumpHost, auth []ssh.AuthMethod, finalConfig *ssh.ClientConfig) *ssh.ClientConfig {
return &ssh.ClientConfig{
User: jump.Username,
Auth: auth,
HostKeyCallback: finalConfig.HostKeyCallback,
Timeout: finalConfig.Timeout,
}
}
// parsePrivateKey loads and parses an SSH private key file. If the key is
// encrypted and getPassphrase is provided, it will attempt decryption.
func (c *Client) parsePrivateKey(path string, getPassphrase func() (string, error)) (ssh.Signer, error) {
+49
View File
@@ -0,0 +1,49 @@
package sshclient
import (
"net"
"testing"
"time"
"github.com/andydixon/nexus/internal/models"
"golang.org/x/crypto/ssh"
)
func TestJumpHostClientConfigUsesFinalHostKeyCallback(t *testing.T) {
called := false
hostKeyCallback := func(hostname string, _ net.Addr, _ ssh.PublicKey) error {
called = true
if hostname != "jump.example:2222" {
t.Fatalf("hostname = %q, want jump.example:2222", hostname)
}
return nil
}
finalConfig := &ssh.ClientConfig{
HostKeyCallback: hostKeyCallback,
Timeout: 12 * time.Second,
}
jump := models.JumpHost{
Host: "jump.example",
Port: 2222,
Username: "alice",
}
got := jumpHostClientConfig(jump, nil, finalConfig)
if got.User != jump.Username {
t.Fatalf("User = %q, want %q", got.User, jump.Username)
}
if got.Timeout != finalConfig.Timeout {
t.Fatalf("Timeout = %s, want %s", got.Timeout, finalConfig.Timeout)
}
if got.HostKeyCallback == nil {
t.Fatal("HostKeyCallback is nil")
}
if err := got.HostKeyCallback("jump.example:2222", nil, nil); err != nil {
t.Fatalf("HostKeyCallback() error = %v", err)
}
if !called {
t.Fatal("final host key callback was not used")
}
}
+27
View File
@@ -3,6 +3,7 @@ package storage
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
@@ -455,6 +456,32 @@ func TestExportProfiles(t *testing.T) {
}
}
func TestExportProfilesRedactsInlineSSHSecrets(t *testing.T) {
store := setupTestStore(t)
profile := makeSSHProfile("Export Secret", "secret.example.com")
profile.SSH.ProxyPassword = "proxy-secret"
profile.SSH.TOTPSecret = "totp-secret"
if err := store.CreateProfile(profile); err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
data, err := store.ExportProfiles(false)
if err != nil {
t.Fatalf("ExportProfiles(false) error = %v", err)
}
if strings.Contains(string(data), "proxy-secret") || strings.Contains(string(data), "totp-secret") {
t.Fatalf("ExportProfiles(false) leaked inline SSH secrets: %s", string(data))
}
data, err = store.ExportProfiles(true)
if err != nil {
t.Fatalf("ExportProfiles(true) error = %v", err)
}
if !strings.Contains(string(data), "proxy-secret") || !strings.Contains(string(data), "totp-secret") {
t.Fatalf("ExportProfiles(true) did not include inline SSH secrets: %s", string(data))
}
}
func TestImportProfiles(t *testing.T) {
store := setupTestStore(t)
p := makeSSHProfile("Import Me", "import.local")
+27 -10
View File
@@ -346,13 +346,15 @@ func (s *Store) UpdateLastConnected(id string) error {
}
// ExportProfiles serialises all profiles as JSON. If includeSecrets is false
// the export omits credential-related fields (currently a placeholder — all
// secrets live in the SecretStore, not in profiles).
// the export omits inline credential-related fields.
func (s *Store) ExportProfiles(includeSecrets bool) ([]byte, error) {
profiles, err := s.ListProfiles()
if err != nil {
return nil, fmt.Errorf("storage: export failed: %w", err)
}
if !includeSecrets {
profiles = redactProfileSecrets(profiles)
}
return json.MarshalIndent(profiles, "", " ")
}
@@ -567,11 +569,11 @@ func (s *Store) SavePreferences(prefs *models.AppPreferences) error {
func scanProfile(row *sql.Row) (*models.Profile, error) {
p := &models.Profile{}
var (
protocol, tagsJSON string
protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString
favourite int
createdAt, updatedAt string
lastConn sql.NullString
favourite int
createdAt, updatedAt string
)
err := row.Scan(
@@ -634,11 +636,11 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
for rows.Next() {
p := &models.Profile{}
var (
protocol, tagsJSON string
protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString
favourite int
createdAt, updatedAt string
lastConn sql.NullString
favourite int
createdAt, updatedAt string
)
err := rows.Scan(
@@ -720,6 +722,21 @@ func nullableJSON(ptr interface{}, data []byte) interface{} {
return string(data)
}
func redactProfileSecrets(profiles []*models.Profile) []*models.Profile {
redacted := make([]*models.Profile, len(profiles))
for i, profile := range profiles {
cp := *profile
if cp.SSH != nil {
sshCfg := *cp.SSH
sshCfg.ProxyPassword = ""
sshCfg.TOTPSecret = ""
cp.SSH = &sshCfg
}
redacted[i] = &cp
}
return redacted
}
// ---------------------------------------------------------------------------
// Snippet CRUD
// ---------------------------------------------------------------------------
+41 -21
View File
@@ -4,6 +4,8 @@ package vncclient
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/http"
@@ -17,14 +19,14 @@ import (
// Client manages a VNC session, optionally tunnelled through SSH.
type Client struct {
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
listener net.Listener
sshClient *ssh.Client
wsURL string
connected bool
onError func(error)
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
listener net.Listener
sshClient *ssh.Client
wsURL string
connected bool
onError func(error)
}
// Config holds the configuration for a VNC connection.
@@ -34,16 +36,17 @@ type Config struct {
VNCPort int
// SSH tunnel settings
SSHTunnel bool
SSHHost string
SSHPort int
SSHUsername string
SSHAuthMethod string // "password", "publickey"
SSHPassword string
SSHPrivateKey []byte
SSHPassphrase string
SSHAgentAuth bool
SSHAgentConn net.Conn
SSHTunnel bool
SSHHost string
SSHPort int
SSHUsername string
SSHAuthMethod string // "password", "publickey"
SSHPassword string
SSHPrivateKey []byte
SSHPassphrase string
SSHAgentAuth bool
SSHAgentConn net.Conn
SSHHostKeyCallback ssh.HostKeyCallback
// Callbacks
OnError func(error)
@@ -83,9 +86,12 @@ func (c *Client) Connect(cfg Config) (string, error) {
if cfg.SSHTunnel {
// Establish SSH connection first
sshAddr := fmt.Sprintf("%s:%d", cfg.SSHHost, cfg.SSHPort)
if cfg.SSHHostKeyCallback == nil {
return "", fmt.Errorf("SSH host key callback is required")
}
sshConfig := &ssh.ClientConfig{
User: cfg.SSHUsername,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: use known hosts
HostKeyCallback: cfg.SSHHostKeyCallback,
Timeout: 15 * time.Second,
}
@@ -162,13 +168,19 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
if err != nil {
return "", err
}
token, err := newProxyToken()
if err != nil {
listener.Close()
return "", err
}
c.listener = listener
port := listener.Addr().(*net.TCPAddr).Port
wsURL := fmt.Sprintf("ws://127.0.0.1:%d", port)
proxyPath := "/" + token
wsURL := fmt.Sprintf("ws://127.0.0.1:%d%s", port, proxyPath)
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc(proxyPath, func(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
@@ -240,6 +252,14 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
return wsURL, nil
}
func newProxyToken() (string, error) {
token := make([]byte, 16)
if _, err := rand.Read(token); err != nil {
return "", fmt.Errorf("generate proxy token: %w", err)
}
return hex.EncodeToString(token), nil
}
// Close shuts down the VNC proxy and any SSH tunnel.
func (c *Client) Close() {
c.mu.Lock()
+26
View File
@@ -0,0 +1,26 @@
package vncclient
import "testing"
func TestNewProxyTokenIsRandomHex(t *testing.T) {
first, err := newProxyToken()
if err != nil {
t.Fatalf("newProxyToken() error = %v", err)
}
second, err := newProxyToken()
if err != nil {
t.Fatalf("newProxyToken() second error = %v", err)
}
if len(first) != 32 {
t.Fatalf("token length = %d, want 32", len(first))
}
if first == second {
t.Fatal("expected separate tokens to differ")
}
for _, ch := range first {
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) {
t.Fatalf("token contains non-hex character %q", ch)
}
}
}