package validation import ( "errors" "html" "regexp" "strings" "time" "unicode/utf8" ) // ContactFormRequest represents a validated contact form submission type ContactFormRequest struct { Name string `json:"name" validate:"required,trim,max=100,pattern=name,no_injection"` Email string `json:"email" validate:"required,trim,max=254,email,no_injection"` Company string `json:"company" validate:"optional,trim,max=100,pattern=company"` Subject string `json:"subject" validate:"required,trim,max=200,pattern=subject,no_injection"` Message string `json:"message" validate:"required,trim,max=5000,sanitize"` Honeypot string `json:"website" validate:"honeypot"` // Should always be empty (bot trap) Timestamp int64 `json:"timestamp" validate:"timing=2:86400"` // Form load time (set by server) } // ValidationError represents a validation error with field context type ValidationError struct { Field string Message string } // Error implements the error interface func (e *ValidationError) Error() string { return e.Field + ": " + e.Message } // ValidateContactForm performs comprehensive validation on contact form data func ValidateContactForm(req *ContactFormRequest) error { // 1. Honeypot check (bot detection) if req.Honeypot != "" { return &ValidationError{ Field: "website", Message: "Bot detected", } } // 2. Timing check (form must be displayed for at least 2 seconds) if req.Timestamp > 0 { now := time.Now().Unix() timeTaken := now - req.Timestamp if timeTaken < 2 { return &ValidationError{ Field: "timestamp", Message: "Form submitted too quickly (bot detected)", } } // Also reject if timestamp is in the future or too old (> 24 hours) if timeTaken < 0 || timeTaken > 86400 { return &ValidationError{ Field: "timestamp", Message: "Invalid timestamp", } } } // 3. Required fields if strings.TrimSpace(req.Name) == "" { return &ValidationError{ Field: "name", Message: "Name is required", } } if strings.TrimSpace(req.Email) == "" { return &ValidationError{ Field: "email", Message: "Email is required", } } if strings.TrimSpace(req.Subject) == "" { return &ValidationError{ Field: "subject", Message: "Subject is required", } } if strings.TrimSpace(req.Message) == "" { return &ValidationError{ Field: "message", Message: "Message is required", } } // 4. Length validation if utf8.RuneCountInString(req.Name) > 100 { return &ValidationError{ Field: "name", Message: "Name must be 100 characters or less", } } if utf8.RuneCountInString(req.Email) > 254 { return &ValidationError{ Field: "email", Message: "Email must be 254 characters or less", } } if utf8.RuneCountInString(req.Company) > 100 { return &ValidationError{ Field: "company", Message: "Company must be 100 characters or less", } } if utf8.RuneCountInString(req.Subject) > 200 { return &ValidationError{ Field: "subject", Message: "Subject must be 200 characters or less", } } if utf8.RuneCountInString(req.Message) > 5000 { return &ValidationError{ Field: "message", Message: "Message must be 5000 characters or less", } } // 5. Email validation (RFC 5322) if !IsValidEmail(req.Email) { return &ValidationError{ Field: "email", Message: "Invalid email address format", } } // 6. Email header injection prevention if ContainsEmailInjection(req.Name) { return &ValidationError{ Field: "name", Message: "Name contains invalid characters", } } if ContainsEmailInjection(req.Email) { return &ValidationError{ Field: "email", Message: "Email contains invalid characters", } } if ContainsEmailInjection(req.Subject) { return &ValidationError{ Field: "subject", Message: "Subject contains invalid characters", } } // 7. Name validation (letters, spaces, hyphens, apostrophes only) if !IsValidName(req.Name) { return &ValidationError{ Field: "name", Message: "Name can only contain letters, spaces, hyphens, and apostrophes", } } // 8. Subject validation (alphanumeric + safe punctuation) if !IsValidSubject(req.Subject) { return &ValidationError{ Field: "subject", Message: "Subject contains invalid characters", } } // 9. Company validation (optional, but if provided must be alphanumeric) if req.Company != "" && !IsValidCompany(req.Company) { return &ValidationError{ Field: "company", Message: "Company name contains invalid characters", } } return nil } // IsValidEmail validates email format per RFC 5322 (simplified) func IsValidEmail(email string) bool { email = strings.TrimSpace(email) // Basic length check if len(email) < 3 || len(email) > 254 { return false } // Must contain @ parts := strings.Split(email, "@") if len(parts) != 2 { return false } local := parts[0] domain := parts[1] // Local part validation if len(local) == 0 || len(local) > 64 { return false } // Domain must have at least one dot (TLD required) if !strings.Contains(domain, ".") { return false } // RFC 5322 simplified regex // This is a reasonable approximation - full RFC 5322 is extremely complex emailRegex := regexp.MustCompile(`^[a-zA-Z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[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])?)+$`) return emailRegex.MatchString(email) } // ContainsEmailInjection checks for email header injection attempts // Email header injection: attacker tries to inject additional headers via newlines func ContainsEmailInjection(s string) bool { // Check for newlines (CRLF or LF) if strings.ContainsAny(s, "\r\n") { return true } // Check for email header patterns (case-insensitive) sLower := strings.ToLower(s) dangerousPatterns := []string{ "content-type:", "mime-version:", "content-transfer-encoding:", "bcc:", "cc:", "to:", "from:", "subject:", "reply-to:", "x-mailer:", } for _, pattern := range dangerousPatterns { if strings.Contains(sLower, pattern) { return true } } return false } // IsValidName validates name format // Allows: letters (any language), spaces, hyphens, apostrophes func IsValidName(name string) bool { name = strings.TrimSpace(name) if name == "" { return false } // Allow unicode letters, spaces, hyphens, apostrophes // This supports international names (Juan José, François, 田中, etc.) nameRegex := regexp.MustCompile(`^[\p{L}\s'-]+$`) return nameRegex.MatchString(name) } // IsValidSubject validates subject format // Allows: alphanumeric, spaces, and common punctuation (including #) func IsValidSubject(subject string) bool { subject = strings.TrimSpace(subject) if subject == "" { return false } // Allow alphanumeric (any language), spaces, and safe punctuation (including #) subjectRegex := regexp.MustCompile(`^[\p{L}\p{N}\s.,!?'"()\-:;#]+$`) return subjectRegex.MatchString(subject) } // IsValidCompany validates company name format // Allows: alphanumeric, spaces, and common business punctuation func IsValidCompany(company string) bool { company = strings.TrimSpace(company) if company == "" { return true // Optional field } // Allow alphanumeric (any language), spaces, and business punctuation companyRegex := regexp.MustCompile(`^[\p{L}\p{N}\s.,&'()\-]+$`) return companyRegex.MatchString(company) } // SanitizeContactForm sanitizes contact form data // This should be called AFTER validation func SanitizeContactForm(req *ContactFormRequest) { // 1. Trim whitespace req.Name = strings.TrimSpace(req.Name) req.Email = strings.TrimSpace(req.Email) req.Company = strings.TrimSpace(req.Company) req.Subject = strings.TrimSpace(req.Subject) req.Message = strings.TrimSpace(req.Message) // 2. Remove any newlines from header fields (belt-and-suspenders) req.Name = removeNewlines(req.Name) req.Email = removeNewlines(req.Email) req.Company = removeNewlines(req.Company) req.Subject = removeNewlines(req.Subject) // 3. HTML escape message body (prevent XSS in email clients) req.Message = html.EscapeString(req.Message) // 4. Normalize whitespace in message (collapse multiple spaces/newlines) req.Message = normalizeWhitespace(req.Message) } // removeNewlines removes all newline characters func removeNewlines(s string) string { s = strings.ReplaceAll(s, "\r", "") s = strings.ReplaceAll(s, "\n", "") return s } // normalizeWhitespace collapses multiple spaces/newlines to single instances func normalizeWhitespace(s string) string { // Replace multiple newlines with double newline (paragraph break) newlineRegex := regexp.MustCompile(`\n{3,}`) s = newlineRegex.ReplaceAllString(s, "\n\n") // Replace multiple spaces (but not newlines) with single space spaceRegex := regexp.MustCompile(`[^\S\n]+`) s = spaceRegex.ReplaceAllString(s, " ") return strings.TrimSpace(s) } // Common validation errors var ( ErrBotDetected = errors.New("bot detected") ErrInvalidEmail = errors.New("invalid email format") ErrEmailInjection = errors.New("email injection attempt detected") ErrInvalidName = errors.New("invalid name format") ErrInvalidSubject = errors.New("invalid subject format") ErrRequiredField = errors.New("required field missing") ErrFieldTooLong = errors.New("field exceeds maximum length") ErrSubmittedTooFast = errors.New("form submitted too quickly") ) // Global validator instance for reusing cached struct metadata var globalValidator = NewValidator() // ValidateContactFormV2 validates a contact form using struct tags // This is the new validation method that uses the tag-based validator func ValidateContactFormV2(req *ContactFormRequest) error { return globalValidator.Validate(req) }