fix: harden profile imports and release dependencies

This commit is contained in:
Andy Dixon
2026-06-15 19:39:16 +01:00
parent b2fc84846c
commit 9bbd9ceccf
20 changed files with 1493 additions and 135 deletions
+80
View File
@@ -32,6 +32,7 @@ import (
"github.com/andydixon/nexus/internal/sshconfig"
"github.com/andydixon/nexus/internal/storage"
"github.com/andydixon/nexus/internal/updater"
"github.com/andydixon/nexus/internal/validation"
"github.com/google/uuid"
)
@@ -41,6 +42,11 @@ var appVersion = "dev"
// appUpdateURL is set at build time via ldflags.
var appUpdateURL = ""
const (
maxProfileImportJSONBytes = 5 * 1024 * 1024
maxInlineSFTPUploadBytes = 25 * 1024 * 1024
)
// Service is the main API surface exposed to the frontend via Wails bindings.
type Service struct {
store *storage.Store
@@ -90,6 +96,7 @@ func (s *Service) CreateProfile(profile models.Profile) (models.Profile, error)
if profile.ID == "" {
profile.ID = uuid.New().String()
}
normaliseProfileForSave(&profile)
now := time.Now()
profile.CreatedAt = now
profile.UpdatedAt = now
@@ -98,6 +105,9 @@ func (s *Service) CreateProfile(profile models.Profile) (models.Profile, error)
profile.Tags = []string{}
}
if err := validation.ValidateProfile(&profile); err != nil {
return models.Profile{}, err
}
if err := s.store.CreateProfile(&profile); err != nil {
return models.Profile{}, fmt.Errorf("failed to create profile: %w", err)
}
@@ -116,7 +126,11 @@ func (s *Service) GetProfile(id string) (models.Profile, error) {
// UpdateProfile persists changes to an existing profile.
func (s *Service) UpdateProfile(profile models.Profile) (models.Profile, error) {
normaliseProfileForSave(&profile)
profile.UpdatedAt = time.Now()
if err := validation.ValidateProfile(&profile); err != nil {
return models.Profile{}, err
}
if err := s.store.UpdateProfile(&profile); err != nil {
return models.Profile{}, fmt.Errorf("failed to update profile: %w", err)
}
@@ -699,11 +713,21 @@ func (s *Service) ExportProfiles(includeSecrets bool) (string, error) {
// - "overwrite" replaces existing profiles
// - "duplicate" always creates a new profile with a fresh ID
func (s *Service) ImportProfiles(jsonData string, strategy string) (int, error) {
if len(jsonData) > maxProfileImportJSONBytes {
return 0, fmt.Errorf("import data is too large (max %d bytes)", maxProfileImportJSONBytes)
}
var profiles []*models.Profile
if err := json.Unmarshal([]byte(jsonData), &profiles); err != nil {
return 0, fmt.Errorf("invalid JSON: %w", err)
}
if err := validateImportStrategy(strategy); err != nil {
return 0, err
}
if err := validateImportProfiles(profiles); err != nil {
return 0, err
}
imported := 0
for _, p := range profiles {
switch strategy {
@@ -1154,6 +1178,17 @@ func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int
return 0, err
}
if err := validateImportStrategy(strategy); err != nil {
return 0, err
}
profilePtrs := make([]*models.Profile, len(profiles))
for i := range profiles {
profilePtrs[i] = &profiles[i]
}
if err := validateImportProfiles(profilePtrs); err != nil {
return 0, err
}
// Import using the existing strategy logic.
imported := 0
for _, p := range profiles {
@@ -1302,11 +1337,17 @@ func (s *Service) SFTPUploadData(sessionID string, remoteDir string, fileName st
if base64Data == "" {
return fmt.Errorf("empty upload payload")
}
if base64.StdEncoding.DecodedLen(len(base64Data)) > maxInlineSFTPUploadBytes {
return fmt.Errorf("upload payload is too large (max %d bytes)", maxInlineSFTPUploadBytes)
}
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return fmt.Errorf("failed to decode upload data: %w", err)
}
if len(data) > maxInlineSFTPUploadBytes {
return fmt.Errorf("upload payload is too large (max %d bytes)", maxInlineSFTPUploadBytes)
}
safeName := filepath.Base(fileName)
if safeName == "" || safeName == "." || safeName == "/" {
@@ -1710,6 +1751,45 @@ func validateSSHKeyName(name string) (string, error) {
return name, nil
}
func validateImportStrategy(strategy string) error {
switch strategy {
case "skip", "overwrite", "duplicate":
return nil
default:
return fmt.Errorf("unknown import strategy %q (expected skip, overwrite, or duplicate)", strategy)
}
}
func validateImportProfiles(profiles []*models.Profile) error {
for i, p := range profiles {
if p == nil {
return fmt.Errorf("profile import entry %d is null", i)
}
normaliseProfileForSave(p)
if err := validation.ValidateProfile(p); err != nil {
return fmt.Errorf("profile import entry %d is invalid: %w", i, err)
}
}
return nil
}
func normaliseProfileForSave(profile *models.Profile) {
if profile == nil {
return
}
if profile.Tags == nil {
profile.Tags = []string{}
}
if profile.VNC != nil {
if profile.VNC.SecretMode == "prompt" {
profile.VNC.SecretMode = models.SecretModeAskEveryTime
}
if profile.VNC.SSHSecretMode == "prompt" {
profile.VNC.SSHSecretMode = models.SecretModeAskEveryTime
}
}
}
func redactProfileSecrets(profiles []*models.Profile) []*models.Profile {
redacted := make([]*models.Profile, len(profiles))
for i, profile := range profiles {
+57
View File
@@ -1,11 +1,14 @@
package api
import (
"encoding/base64"
"path/filepath"
"strings"
"testing"
"github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/storage"
)
func TestSSHKeyPairPathsAcceptsSafeName(t *testing.T) {
@@ -65,3 +68,57 @@ func TestRedactProfileSecretsClearsInlineSSHSecrets(t *testing.T) {
t.Fatal("redaction mutated the original profile")
}
}
func TestCreateProfileRejectsUnsafeGitHubBaseURL(t *testing.T) {
svc := newTestService(t)
profile := models.NewProfile(models.ProtocolGitHub)
profile.DisplayName = "Unsafe GitHub"
profile.GitHub.BaseURL = "http://evil.example/api/v3"
if _, err := svc.CreateProfile(profile); err == nil {
t.Fatal("expected unsafe GitHub base URL to be rejected")
}
}
func TestImportProfilesRejectsNullEntry(t *testing.T) {
svc := newTestService(t)
_, err := svc.ImportProfiles(`[null]`, "skip")
if err == nil {
t.Fatal("expected null import entry to be rejected")
}
if !strings.Contains(err.Error(), "entry 0 is null") {
t.Fatalf("error = %q, want null entry detail", err.Error())
}
}
func TestImportProfilesRejectsOversizedJSON(t *testing.T) {
svc := newTestService(t)
oversized := strings.Repeat(" ", maxProfileImportJSONBytes+1)
_, err := svc.ImportProfiles(oversized, "skip")
if err == nil {
t.Fatal("expected oversized import JSON to be rejected")
}
}
func TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup(t *testing.T) {
svc := newTestService(t)
payload := base64.StdEncoding.EncodeToString(make([]byte, maxInlineSFTPUploadBytes+1))
err := svc.SFTPUploadData("missing-session", "/", "large.bin", payload)
if err == nil {
t.Fatal("expected oversized upload payload to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("error = %q, want too large", err.Error())
}
}
func newTestService(t *testing.T) *Service {
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 NewService(store, nil, nil, nil, audit.NewLogger(store), nil)
}