-
This commit is contained in:
375
internal/validation/validator.go
Normal file
375
internal/validation/validator.go
Normal file
@ -0,0 +1,375 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user