6c7595b041
Implement a declarative struct tag validation system for Go: - Add validator.go with sync.Map caching for reflection metadata - Add rules.go with 11 built-in validation rules (required, email, pattern, honeypot, timing, etc.) - Add errors.go with FieldError and ValidationErrors types - Update ContactFormRequest with validate tags - Add ValidateContactFormV2() using the new tag-based validator Rules implemented: - required/optional: field presence validation - trim/sanitize: automatic value transformations - min/max: UTF-8 aware length validation - email: RFC 5322 email format validation - pattern: predefined regex patterns (name, subject, company) - no_injection: email header injection prevention - honeypot: bot trap (must be empty) - timing: timestamp validation for bot detection Documentation: - docs/go-validation-system.md: complete validation guide - docs/go-template-system.md: template manager documentation - docs/go-routes-api.md: routes and API reference - docs/README.md: documentation index
362 lines
9.6 KiB
Go
362 lines
9.6 KiB
Go
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)
|
|
}
|