Files
cv-site/internal/validation/contact.go
T
juanatsap 6c7595b041 feat: add tag-based validation system with reflection caching
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
2025-12-06 15:20:45 +00:00

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)
}