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
+121 -8
View File
@@ -5,6 +5,7 @@ package validation
import (
"fmt"
"net"
"net/url"
"strings"
"github.com/andydixon/nexus/internal/models"
@@ -50,21 +51,20 @@ func (e *ValidationErrors) Add(field, message string) {
func ValidateProfile(p *models.Profile) error {
errs := &ValidationErrors{}
if p == nil {
return &ValidationError{Field: "profile", Message: "profile must not be nil"}
}
if strings.TrimSpace(p.ID) == "" {
errs.Add("id", "profile ID must not be empty")
}
if strings.TrimSpace(p.DisplayName) == "" {
errs.Add("displayName", "display name must not be empty")
}
if err := ValidateHost(p.Host); err != nil {
errs.Add("host", err.(*ValidationError).Message)
}
if err := ValidatePort(p.Port); err != nil {
errs.Add("port", err.(*ValidationError).Message)
}
switch p.Protocol {
case models.ProtocolSSH:
validateProfileHostPort(errs, p.Host, p.Port)
if strings.TrimSpace(p.Username) == "" {
errs.Add("username", "username is required for SSH connections")
} else if err := ValidateUsername(p.Username); err != nil {
@@ -78,6 +78,7 @@ func ValidateProfile(p *models.Profile) error {
}
}
case models.ProtocolTelnet:
validateProfileHostPort(errs, p.Host, p.Port)
if p.Telnet == nil {
errs.Add("telnet", "Telnet configuration is required for Telnet protocol")
} else if err := ValidateTelnetConfig(p.Telnet); err != nil {
@@ -86,6 +87,7 @@ func ValidateProfile(p *models.Profile) error {
}
}
case models.ProtocolRAW:
validateProfileHostPort(errs, p.Host, p.Port)
if p.RAW == nil {
errs.Add("raw", "RAW configuration is required for RAW protocol")
} else if err := ValidateRAWConfig(p.RAW); err != nil {
@@ -94,9 +96,21 @@ func ValidateProfile(p *models.Profile) error {
}
}
case models.ProtocolGitHub:
// GitHub profiles require no host-level validation beyond the common checks.
if p.GitHub == nil {
errs.Add("github", "GitHub configuration is required for GitHub protocol")
} else if err := ValidateGitHubConfig(p.GitHub); err != nil {
if ve, ok := err.(*ValidationErrors); ok {
errs.Errors = append(errs.Errors, ve.Errors...)
}
}
case models.ProtocolVNC:
// VNC profiles use vnc.vncHost or the profile-level host; validated at connect time.
if p.VNC == nil {
errs.Add("vnc", "VNC configuration is required for VNC protocol")
} else if err := ValidateVNCConfig(p.Host, p.VNC); err != nil {
if ve, ok := err.(*ValidationErrors); ok {
errs.Errors = append(errs.Errors, ve.Errors...)
}
}
default:
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
}
@@ -120,6 +134,15 @@ func ValidateProfile(p *models.Profile) error {
return nil
}
func validateProfileHostPort(errs *ValidationErrors, host string, port int) {
if err := ValidateHost(host); err != nil {
errs.Add("host", err.(*ValidationError).Message)
}
if err := ValidatePort(port); err != nil {
errs.Add("port", err.(*ValidationError).Message)
}
}
// ValidateHost ensures the host string is non-empty and syntactically valid.
func ValidateHost(host string) error {
h := strings.TrimSpace(host)
@@ -298,3 +321,93 @@ func ValidateRAWConfig(cfg *models.RAWConfig) error {
}
return nil
}
// ValidateGitHubConfig validates GitHub API settings before tokens are sent.
func ValidateGitHubConfig(cfg *models.GitHubConfig) error {
errs := &ValidationErrors{}
if cfg == nil {
return &ValidationError{Field: "github", Message: "GitHub configuration must not be nil"}
}
u, err := url.Parse(strings.TrimSpace(cfg.BaseURL))
if err != nil || u.Scheme == "" || u.Host == "" {
errs.Add("github.baseURL", "API base URL must be an absolute HTTPS URL")
} else {
if u.User != nil {
errs.Add("github.baseURL", "API base URL must not include credentials")
}
if u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHost(u.Hostname())) {
errs.Add("github.baseURL", "API base URL must use HTTPS unless it points to loopback")
}
}
validSecret := map[models.SecretMode]bool{
models.SecretModeSaved: true, models.SecretModeSessionOnly: true, models.SecretModeAskEveryTime: true,
}
if !validSecret[cfg.SecretMode] {
errs.Add("github.secretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SecretMode))
}
if cfg.RefreshIntervalSecs != 0 && cfg.RefreshIntervalSecs < 60 {
errs.Add("github.refreshIntervalSecs", "refresh interval must be at least 60 seconds")
}
if errs.HasErrors() {
return errs
}
return nil
}
// ValidateVNCConfig validates VNC connection settings.
func ValidateVNCConfig(profileHost string, cfg *models.VNCConfig) error {
errs := &ValidationErrors{}
if cfg == nil {
return &ValidationError{Field: "vnc", Message: "VNC configuration must not be nil"}
}
vncHost := strings.TrimSpace(cfg.VNCHost)
if vncHost == "" {
vncHost = strings.TrimSpace(profileHost)
}
if err := ValidateHost(vncHost); err != nil {
errs.Add("vnc.vncHost", err.(*ValidationError).Message)
}
if err := ValidatePort(cfg.VNCPort); err != nil {
errs.Add("vnc.vncPort", err.(*ValidationError).Message)
}
if cfg.Quality < 0 || cfg.Quality > 9 {
errs.Add("vnc.quality", "quality must be between 0 and 9")
}
if cfg.SSHTunnel {
if err := ValidateHost(cfg.SSHHost); err != nil {
errs.Add("vnc.sshHost", err.(*ValidationError).Message)
}
if err := ValidatePort(cfg.SSHPort); err != nil {
errs.Add("vnc.sshPort", err.(*ValidationError).Message)
}
if strings.TrimSpace(cfg.SSHUsername) == "" {
errs.Add("vnc.sshUsername", "SSH username is required when tunnelling")
} else if err := ValidateUsername(cfg.SSHUsername); err != nil {
errs.Add("vnc.sshUsername", err.(*ValidationError).Message)
}
if cfg.SSHAuthMethod != "password" && cfg.SSHAuthMethod != "publickey" {
errs.Add("vnc.sshAuthMethod", fmt.Sprintf("unsupported SSH auth method %q", cfg.SSHAuthMethod))
}
}
if errs.HasErrors() {
return errs
}
return nil
}
func isLoopbackHost(host string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if h == "localhost" {
return true
}
ip := net.ParseIP(h)
return ip != nil && ip.IsLoopback()
}
+142
View File
@@ -127,6 +127,14 @@ func TestValidateProfile(t *testing.T) {
return &p
}
validVNC := func() *models.Profile {
p := models.NewProfile(models.ProtocolVNC)
p.DisplayName = "Test VNC"
p.Host = ""
p.VNC.VNCHost = "desktop.local"
return &p
}
tests := []struct {
name string
profile *models.Profile
@@ -135,6 +143,8 @@ func TestValidateProfile(t *testing.T) {
{name: "full valid SSH profile", profile: validSSH(), wantErr: false},
{name: "full valid Telnet profile", profile: validTelnet(), wantErr: false},
{name: "full valid RAW profile", profile: validRAW(), wantErr: false},
{name: "valid VNC profile with VNC-specific host", profile: validVNC(), wantErr: false},
{name: "nil profile", profile: nil, wantErr: true},
{
name: "missing host",
profile: func() *models.Profile {
@@ -210,6 +220,138 @@ func TestValidateProfile(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// ValidateGitHubConfig
// ---------------------------------------------------------------------------
func TestValidateGitHubConfig(t *testing.T) {
tests := []struct {
name string
cfg *models.GitHubConfig
wantErr bool
}{
{
name: "valid github.com API URL",
cfg: &models.GitHubConfig{
BaseURL: "https://api.github.com",
SecretMode: models.SecretModeSaved,
RefreshIntervalSecs: 300,
},
wantErr: false,
},
{
name: "valid loopback HTTP URL",
cfg: &models.GitHubConfig{
BaseURL: "http://localhost:3000/api/v3",
SecretMode: models.SecretModeAskEveryTime,
RefreshIntervalSecs: 60,
},
wantErr: false,
},
{
name: "rejects non-HTTPS remote URL",
cfg: &models.GitHubConfig{
BaseURL: "http://evil.example/api/v3",
SecretMode: models.SecretModeSaved,
RefreshIntervalSecs: 300,
},
wantErr: true,
},
{
name: "rejects URL credentials",
cfg: &models.GitHubConfig{
BaseURL: "https://token@example.com/api/v3",
SecretMode: models.SecretModeSaved,
RefreshIntervalSecs: 300,
},
wantErr: true,
},
{
name: "rejects aggressive refresh interval",
cfg: &models.GitHubConfig{
BaseURL: "https://api.github.com",
SecretMode: models.SecretModeSaved,
RefreshIntervalSecs: 1,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateGitHubConfig(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateGitHubConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateVNCConfig
// ---------------------------------------------------------------------------
func TestValidateVNCConfig(t *testing.T) {
tests := []struct {
name string
profileHost string
cfg *models.VNCConfig
wantErr bool
}{
{
name: "uses VNC-specific host",
profileHost: "",
cfg: &models.VNCConfig{
VNCHost: "desktop.local",
VNCPort: 5900,
Quality: 6,
},
wantErr: false,
},
{
name: "uses profile host fallback",
profileHost: "desktop.local",
cfg: &models.VNCConfig{
VNCPort: 5900,
Quality: 6,
},
wantErr: false,
},
{
name: "rejects missing host",
profileHost: "",
cfg: &models.VNCConfig{
VNCPort: 5900,
Quality: 6,
},
wantErr: true,
},
{
name: "rejects invalid SSH tunnel auth method",
profileHost: "desktop.local",
cfg: &models.VNCConfig{
VNCPort: 5900,
Quality: 6,
SSHTunnel: true,
SSHHost: "jump.local",
SSHPort: 22,
SSHUsername: "admin",
SSHAuthMethod: "token",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateVNCConfig(tt.profileHost, tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateVNCConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateSSHConfig
// ---------------------------------------------------------------------------