This commit is contained in:
278
internal/validation/validator.go
Normal file
278
internal/validation/validator.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
// Valid pool/dataset name pattern (ZFS naming rules)
|
||||
zfsNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.:]*$`)
|
||||
|
||||
// Valid username pattern
|
||||
usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{2,31}$`)
|
||||
|
||||
// Valid share name pattern (SMB naming rules)
|
||||
shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{0,79}$`)
|
||||
|
||||
// IQN pattern (simplified - iqn.yyyy-mm.reversed.domain:identifier)
|
||||
iqnPattern = regexp.MustCompile(`^iqn\.\d{4}-\d{2}\.[a-zA-Z0-9][a-zA-Z0-9\-\.]*:[a-zA-Z0-9][a-zA-Z0-9\-_\.]*$`)
|
||||
|
||||
// Email pattern (basic)
|
||||
emailPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
// CIDR pattern for NFS clients
|
||||
cidrPattern = regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}(/\d{1,2})?$`)
|
||||
)
|
||||
|
||||
// ValidationError represents a validation error
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
if e.Field != "" {
|
||||
return fmt.Sprintf("validation error on field '%s': %s", e.Field, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("validation error: %s", e.Message)
|
||||
}
|
||||
|
||||
// ValidateZFSName validates a ZFS pool or dataset name
|
||||
func ValidateZFSName(name string) error {
|
||||
if name == "" {
|
||||
return &ValidationError{Field: "name", Message: "name cannot be empty"}
|
||||
}
|
||||
|
||||
if len(name) > 256 {
|
||||
return &ValidationError{Field: "name", Message: "name too long (max 256 characters)"}
|
||||
}
|
||||
|
||||
if !zfsNamePattern.MatchString(name) {
|
||||
return &ValidationError{Field: "name", Message: "invalid characters (allowed: a-z, A-Z, 0-9, _, -, ., :)"}
|
||||
}
|
||||
|
||||
// ZFS names cannot start with certain characters
|
||||
if strings.HasPrefix(name, "-") || strings.HasPrefix(name, ".") {
|
||||
return &ValidationError{Field: "name", Message: "name cannot start with '-' or '.'"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUsername validates a username
|
||||
func ValidateUsername(username string) error {
|
||||
if username == "" {
|
||||
return &ValidationError{Field: "username", Message: "username cannot be empty"}
|
||||
}
|
||||
|
||||
if len(username) < 3 {
|
||||
return &ValidationError{Field: "username", Message: "username too short (min 3 characters)"}
|
||||
}
|
||||
|
||||
if len(username) > 32 {
|
||||
return &ValidationError{Field: "username", Message: "username too long (max 32 characters)"}
|
||||
}
|
||||
|
||||
if !usernamePattern.MatchString(username) {
|
||||
return &ValidationError{Field: "username", Message: "invalid characters (allowed: a-z, A-Z, 0-9, _, -, .)"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePassword validates a password
|
||||
func ValidatePassword(password string) error {
|
||||
if password == "" {
|
||||
return &ValidationError{Field: "password", Message: "password cannot be empty"}
|
||||
}
|
||||
|
||||
if len(password) < 8 {
|
||||
return &ValidationError{Field: "password", Message: "password too short (min 8 characters)"}
|
||||
}
|
||||
|
||||
if len(password) > 128 {
|
||||
return &ValidationError{Field: "password", Message: "password too long (max 128 characters)"}
|
||||
}
|
||||
|
||||
// Check for at least one letter and one number
|
||||
hasLetter := false
|
||||
hasNumber := false
|
||||
|
||||
for _, r := range password {
|
||||
if unicode.IsLetter(r) {
|
||||
hasLetter = true
|
||||
}
|
||||
if unicode.IsNumber(r) {
|
||||
hasNumber = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasLetter {
|
||||
return &ValidationError{Field: "password", Message: "password must contain at least one letter"}
|
||||
}
|
||||
|
||||
if !hasNumber {
|
||||
return &ValidationError{Field: "password", Message: "password must contain at least one number"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEmail validates an email address
|
||||
func ValidateEmail(email string) error {
|
||||
if email == "" {
|
||||
return nil // Email is optional
|
||||
}
|
||||
|
||||
if len(email) > 254 {
|
||||
return &ValidationError{Field: "email", Message: "email too long (max 254 characters)"}
|
||||
}
|
||||
|
||||
if !emailPattern.MatchString(email) {
|
||||
return &ValidationError{Field: "email", Message: "invalid email format"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateShareName validates an SMB share name
|
||||
func ValidateShareName(name string) error {
|
||||
if name == "" {
|
||||
return &ValidationError{Field: "name", Message: "share name cannot be empty"}
|
||||
}
|
||||
|
||||
if len(name) > 80 {
|
||||
return &ValidationError{Field: "name", Message: "share name too long (max 80 characters)"}
|
||||
}
|
||||
|
||||
if !shareNamePattern.MatchString(name) {
|
||||
return &ValidationError{Field: "name", Message: "invalid share name (allowed: a-z, A-Z, 0-9, _, -, .)"}
|
||||
}
|
||||
|
||||
// Reserved names
|
||||
reserved := []string{"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"}
|
||||
upperName := strings.ToUpper(name)
|
||||
for _, r := range reserved {
|
||||
if upperName == r {
|
||||
return &ValidationError{Field: "name", Message: fmt.Sprintf("share name '%s' is reserved", name)}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateIQN validates an iSCSI Qualified Name
|
||||
func ValidateIQN(iqn string) error {
|
||||
if iqn == "" {
|
||||
return &ValidationError{Field: "iqn", Message: "IQN cannot be empty"}
|
||||
}
|
||||
|
||||
if len(iqn) > 223 {
|
||||
return &ValidationError{Field: "iqn", Message: "IQN too long (max 223 characters)"}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(iqn, "iqn.") {
|
||||
return &ValidationError{Field: "iqn", Message: "IQN must start with 'iqn.'"}
|
||||
}
|
||||
|
||||
// Basic format validation (can be more strict)
|
||||
if !iqnPattern.MatchString(iqn) {
|
||||
return &ValidationError{Field: "iqn", Message: "invalid IQN format (expected: iqn.yyyy-mm.reversed.domain:identifier)"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSize validates a size string (e.g., "10G", "1T")
|
||||
func ValidateSize(sizeStr string) error {
|
||||
if sizeStr == "" {
|
||||
return &ValidationError{Field: "size", Message: "size cannot be empty"}
|
||||
}
|
||||
|
||||
// Pattern: number followed by optional unit (K, M, G, T, P)
|
||||
sizePattern := regexp.MustCompile(`^(\d+)([KMGT]?)$`)
|
||||
if !sizePattern.MatchString(strings.ToUpper(sizeStr)) {
|
||||
return &ValidationError{Field: "size", Message: "invalid size format (expected: number with optional unit K, M, G, T, P)"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePath validates a filesystem path
|
||||
func ValidatePath(path string) error {
|
||||
if path == "" {
|
||||
return nil // Path is optional (can be auto-filled)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return &ValidationError{Field: "path", Message: "path must be absolute (start with /)"}
|
||||
}
|
||||
|
||||
if len(path) > 4096 {
|
||||
return &ValidationError{Field: "path", Message: "path too long (max 4096 characters)"}
|
||||
}
|
||||
|
||||
// Check for dangerous path components
|
||||
dangerous := []string{"..", "//", "\x00"}
|
||||
for _, d := range dangerous {
|
||||
if strings.Contains(path, d) {
|
||||
return &ValidationError{Field: "path", Message: fmt.Sprintf("path contains invalid component: %s", d)}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCIDR validates a CIDR notation or hostname
|
||||
func ValidateCIDR(cidr string) error {
|
||||
if cidr == "" {
|
||||
return &ValidationError{Field: "client", Message: "client cannot be empty"}
|
||||
}
|
||||
|
||||
// Allow wildcard
|
||||
if cidr == "*" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a CIDR
|
||||
if cidrPattern.MatchString(cidr) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a valid hostname
|
||||
hostnamePattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
|
||||
if hostnamePattern.MatchString(cidr) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ValidationError{Field: "client", Message: "invalid client format (expected: CIDR, hostname, or '*')"}
|
||||
}
|
||||
|
||||
// SanitizeString removes potentially dangerous characters
|
||||
func SanitizeString(s string) string {
|
||||
// Remove null bytes and control characters
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if r >= 32 && r != 127 {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// SanitizePath sanitizes a filesystem path
|
||||
func SanitizePath(path string) string {
|
||||
// Remove leading/trailing whitespace and normalize slashes
|
||||
path = strings.TrimSpace(path)
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
|
||||
// Remove multiple slashes
|
||||
for strings.Contains(path, "//") {
|
||||
path = strings.ReplaceAll(path, "//", "/")
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
Reference in New Issue
Block a user