package validation import ( "fmt" "net/url" "regexp" "strings" "unicode" "go.uber.org/zap" ) // Validator provides comprehensive input validation type Validator struct { logger *zap.Logger } // NewValidator creates a new input validator func NewValidator(logger *zap.Logger) *Validator { return &Validator{ logger: logger, } } // ValidationError represents a validation error type ValidationError struct { Field string `json:"field"` Message string `json:"message"` Value string `json:"value,omitempty"` } func (e ValidationError) Error() string { return fmt.Sprintf("validation error for field '%s': %s", e.Field, e.Message) } // ValidationResult holds the result of validation type ValidationResult struct { Valid bool `json:"valid"` Errors []ValidationError `json:"errors"` } // AddError adds a validation error func (vr *ValidationResult) AddError(field, message, value string) { vr.Valid = false vr.Errors = append(vr.Errors, ValidationError{ Field: field, Message: message, Value: value, }) } // Regular expressions for validation var ( emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) appIDRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$`) tokenPrefixRegex = regexp.MustCompile(`^[A-Z]{2,4}$`) permissionRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9._]*[a-zA-Z0-9]$`) ) // ValidateEmail validates email addresses func (v *Validator) ValidateEmail(email string) *ValidationResult { result := &ValidationResult{Valid: true} if email == "" { result.AddError("email", "Email is required", "") return result } if len(email) > 254 { result.AddError("email", "Email too long (max 254 characters)", email) return result } if !emailRegex.MatchString(email) { result.AddError("email", "Invalid email format", email) return result } // Additional email security checks if strings.Contains(email, "..") { result.AddError("email", "Email contains consecutive dots", email) return result } // Check for potentially dangerous characters dangerousChars := []string{"<", ">", "\"", "'", "&", ";", "|", "`"} for _, char := range dangerousChars { if strings.Contains(email, char) { result.AddError("email", "Email contains invalid characters", email) return result } } return result } // ValidateAppID validates application IDs func (v *Validator) ValidateAppID(appID string) *ValidationResult { result := &ValidationResult{Valid: true} if appID == "" { result.AddError("app_id", "Application ID is required", "") return result } if len(appID) < 3 || len(appID) > 100 { result.AddError("app_id", "Application ID must be between 3 and 100 characters", appID) return result } if !appIDRegex.MatchString(appID) { result.AddError("app_id", "Application ID must start and end with alphanumeric characters and contain only letters, numbers, dots, hyphens, and underscores", appID) return result } // Check for reserved names reservedNames := []string{"admin", "root", "system", "internal", "api", "www", "mail", "ftp"} for _, reserved := range reservedNames { if strings.EqualFold(appID, reserved) { result.AddError("app_id", "Application ID cannot be a reserved name", appID) return result } } return result } // ValidateURL validates URLs func (v *Validator) ValidateURL(urlStr, fieldName string) *ValidationResult { result := &ValidationResult{Valid: true} if urlStr == "" { result.AddError(fieldName, "URL is required", "") return result } if len(urlStr) > 2000 { result.AddError(fieldName, "URL too long (max 2000 characters)", urlStr) return result } parsedURL, err := url.Parse(urlStr) if err != nil { result.AddError(fieldName, "Invalid URL format", urlStr) return result } // Validate scheme if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { result.AddError(fieldName, "URL must use http or https scheme", urlStr) return result } // Security: Require HTTPS in production (configurable) if parsedURL.Scheme != "https" { v.logger.Warn("Non-HTTPS URL provided", zap.String("url", urlStr)) // In strict mode, this would be an error // result.AddError(fieldName, "HTTPS is required", urlStr) } // Validate host if parsedURL.Host == "" { result.AddError(fieldName, "URL must have a valid host", urlStr) return result } // Security: Block localhost and private IPs in production if v.isPrivateOrLocalhost(parsedURL.Host) { result.AddError(fieldName, "URLs pointing to private or localhost addresses are not allowed", urlStr) return result } return result } // ValidatePermissions validates a list of permissions func (v *Validator) ValidatePermissions(permissions []string) *ValidationResult { result := &ValidationResult{Valid: true} if len(permissions) == 0 { result.AddError("permissions", "At least one permission is required", "") return result } if len(permissions) > 50 { result.AddError("permissions", "Too many permissions (max 50)", fmt.Sprintf("%d", len(permissions))) return result } seen := make(map[string]bool) for i, permission := range permissions { field := fmt.Sprintf("permissions[%d]", i) // Check for duplicates if seen[permission] { result.AddError(field, "Duplicate permission", permission) continue } seen[permission] = true // Validate individual permission if err := v.validateSinglePermission(permission); err != nil { result.AddError(field, err.Error(), permission) } } return result } // ValidateTokenPrefix validates token prefixes func (v *Validator) ValidateTokenPrefix(prefix string) *ValidationResult { result := &ValidationResult{Valid: true} if prefix == "" { // Empty prefix is allowed - will use default return result } if len(prefix) < 2 || len(prefix) > 4 { result.AddError("token_prefix", "Token prefix must be between 2 and 4 characters", prefix) return result } if !tokenPrefixRegex.MatchString(prefix) { result.AddError("token_prefix", "Token prefix must contain only uppercase letters", prefix) return result } return result } // ValidateString validates a general string with length and content constraints func (v *Validator) ValidateString(value, fieldName string, minLen, maxLen int, allowEmpty bool) *ValidationResult { result := &ValidationResult{Valid: true} if value == "" && !allowEmpty { result.AddError(fieldName, fmt.Sprintf("%s is required", fieldName), "") return result } if len(value) < minLen { result.AddError(fieldName, fmt.Sprintf("%s must be at least %d characters", fieldName, minLen), value) return result } if len(value) > maxLen { result.AddError(fieldName, fmt.Sprintf("%s must be at most %d characters", fieldName, maxLen), value) return result } // Check for control characters and other potentially dangerous characters for i, r := range value { if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' { result.AddError(fieldName, fmt.Sprintf("%s contains invalid control character at position %d", fieldName, i), value) return result } } // Check for null bytes if strings.Contains(value, "\x00") { result.AddError(fieldName, fmt.Sprintf("%s contains null bytes", fieldName), value) return result } return result } // ValidateDuration validates duration strings func (v *Validator) ValidateDuration(duration, fieldName string) *ValidationResult { result := &ValidationResult{Valid: true} if duration == "" { result.AddError(fieldName, "Duration is required", "") return result } // Basic duration format validation (Go duration format) durationRegex := regexp.MustCompile(`^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$`) if !durationRegex.MatchString(duration) { result.AddError(fieldName, "Invalid duration format (use Go duration format like '1h', '30m', '5s')", duration) return result } return result } // Helper methods func (v *Validator) validateSinglePermission(permission string) error { if permission == "" { return fmt.Errorf("permission cannot be empty") } if len(permission) > 100 { return fmt.Errorf("permission too long (max 100 characters)") } if !permissionRegex.MatchString(permission) { return fmt.Errorf("permission must start and end with alphanumeric characters and contain only letters, numbers, dots, and underscores") } // Validate permission hierarchy (dots separate levels) parts := strings.Split(permission, ".") for i, part := range parts { if part == "" { return fmt.Errorf("permission level %d is empty", i+1) } if len(part) > 50 { return fmt.Errorf("permission level %d is too long (max 50 characters)", i+1) } } if len(parts) > 5 { return fmt.Errorf("permission hierarchy too deep (max 5 levels)") } return nil } func (v *Validator) isPrivateOrLocalhost(host string) bool { // Remove port if present if colonIndex := strings.LastIndex(host, ":"); colonIndex != -1 { host = host[:colonIndex] } // Check for localhost variants localhosts := []string{"localhost", "127.0.0.1", "::1", "0.0.0.0"} for _, localhost := range localhosts { if strings.EqualFold(host, localhost) { return true } } // Check for private IP ranges (simplified) privateRanges := []string{ "10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.", "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.", } for _, privateRange := range privateRanges { if strings.HasPrefix(host, privateRange) { return true } } return false } // ValidateApplicationRequest validates create/update application requests func (v *Validator) ValidateApplicationRequest(appID, appLink, callbackURL string, permissions []string) []ValidationError { var errors []ValidationError // Validate app ID if result := v.ValidateAppID(appID); !result.Valid { errors = append(errors, result.Errors...) } // Validate app link URL if result := v.ValidateURL(appLink, "app_link"); !result.Valid { errors = append(errors, result.Errors...) } // Validate callback URL if result := v.ValidateURL(callbackURL, "callback_url"); !result.Valid { errors = append(errors, result.Errors...) } // Validate permissions if result := v.ValidatePermissions(permissions); !result.Valid { errors = append(errors, result.Errors...) } return errors }