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
281 lines
6.9 KiB
Go
281 lines
6.9 KiB
Go
package validation
|
|
|
|
import (
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// ValidationRule represents a validation rule function
|
|
type ValidationRule func(field string, value string, param string) *FieldError
|
|
|
|
// Built-in validation rules registry
|
|
var validationRules = map[string]ValidationRule{
|
|
"required": ruleRequired,
|
|
"optional": ruleOptional,
|
|
"trim": ruleTrim,
|
|
"min": ruleMin,
|
|
"max": ruleMax,
|
|
"email": ruleEmail,
|
|
"pattern": rulePattern,
|
|
"no_injection": ruleNoInjection,
|
|
"sanitize": ruleSanitize,
|
|
"honeypot": ruleHoneypot,
|
|
"timing": ruleTiming,
|
|
}
|
|
|
|
// Pre-compiled regex patterns for performance
|
|
var (
|
|
namePattern *regexp.Regexp
|
|
subjectPattern *regexp.Regexp
|
|
companyPattern *regexp.Regexp
|
|
)
|
|
|
|
func init() {
|
|
// Pre-compile patterns at startup for maximum performance
|
|
namePattern = regexp.MustCompile(`^[\p{L}\s'-]+$`)
|
|
subjectPattern = regexp.MustCompile(`^[\p{L}\p{N}\s.,!?'"()\-:;#]+$`)
|
|
companyPattern = regexp.MustCompile(`^[\p{L}\p{N}\s.,&'()\-]+$`)
|
|
}
|
|
|
|
// ruleRequired validates that the field is not empty (after trimming)
|
|
func ruleRequired(field string, value string, param string) *FieldError {
|
|
if strings.TrimSpace(value) == "" {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "required",
|
|
Message: field + " is required",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ruleOptional is a marker rule - it always passes
|
|
// This allows explicit marking of optional fields in tags
|
|
func ruleOptional(field string, value string, param string) *FieldError {
|
|
return nil
|
|
}
|
|
|
|
// ruleTrim validates and modifies the value (trim whitespace)
|
|
// Note: This is a special rule that modifies the value in-place
|
|
func ruleTrim(field string, value string, param string) *FieldError {
|
|
// This rule doesn't validate, it's a marker for the validator to trim
|
|
return nil
|
|
}
|
|
|
|
// ruleMin validates minimum rune length (UTF-8 aware)
|
|
func ruleMin(field string, value string, param string) *FieldError {
|
|
minLen, err := strconv.Atoi(param)
|
|
if err != nil {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "min",
|
|
Param: param,
|
|
Message: "invalid min parameter: " + param,
|
|
}
|
|
}
|
|
|
|
runeCount := utf8.RuneCountInString(value)
|
|
if runeCount < minLen {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "min",
|
|
Param: param,
|
|
Message: field + " must be at least " + param + " characters",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ruleMax validates maximum rune length (UTF-8 aware)
|
|
func ruleMax(field string, value string, param string) *FieldError {
|
|
maxLen, err := strconv.Atoi(param)
|
|
if err != nil {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "max",
|
|
Param: param,
|
|
Message: "invalid max parameter: " + param,
|
|
}
|
|
}
|
|
|
|
runeCount := utf8.RuneCountInString(value)
|
|
if runeCount > maxLen {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "max",
|
|
Param: param,
|
|
Message: field + " must be " + param + " characters or less",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ruleEmail validates email format using the existing IsValidEmail function
|
|
func ruleEmail(field string, value string, param string) *FieldError {
|
|
if value == "" {
|
|
return nil // Skip validation for empty values (use 'required' if needed)
|
|
}
|
|
|
|
if !IsValidEmail(value) {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "email",
|
|
Message: "Invalid email address format",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// rulePattern validates against predefined patterns
|
|
// Supported patterns: name, subject, company
|
|
func rulePattern(field string, value string, param string) *FieldError {
|
|
if value == "" {
|
|
return nil // Skip validation for empty values
|
|
}
|
|
|
|
var valid bool
|
|
var patternName string
|
|
|
|
switch param {
|
|
case "name":
|
|
valid = namePattern.MatchString(strings.TrimSpace(value))
|
|
patternName = "name (letters, spaces, hyphens, apostrophes only)"
|
|
case "subject":
|
|
valid = subjectPattern.MatchString(strings.TrimSpace(value))
|
|
patternName = "subject (alphanumeric and safe punctuation only)"
|
|
case "company":
|
|
valid = companyPattern.MatchString(strings.TrimSpace(value))
|
|
patternName = "company (alphanumeric and business punctuation only)"
|
|
default:
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "pattern",
|
|
Param: param,
|
|
Message: "unknown pattern: " + param,
|
|
}
|
|
}
|
|
|
|
if !valid {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "pattern",
|
|
Param: param,
|
|
Message: field + " contains invalid characters for " + patternName,
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ruleNoInjection validates that the value doesn't contain email header injection attempts
|
|
func ruleNoInjection(field string, value string, param string) *FieldError {
|
|
if value == "" {
|
|
return nil // Skip validation for empty values
|
|
}
|
|
|
|
if ContainsEmailInjection(value) {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "no_injection",
|
|
Message: field + " contains invalid characters (possible injection attempt)",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ruleSanitize is a marker rule for HTML sanitization
|
|
// The actual sanitization happens in the validator
|
|
func ruleSanitize(field string, value string, param string) *FieldError {
|
|
// This rule doesn't validate, it's a marker for the validator to sanitize
|
|
return nil
|
|
}
|
|
|
|
// ruleHoneypot validates that the honeypot field is empty (bot detection)
|
|
func ruleHoneypot(field string, value string, param string) *FieldError {
|
|
if value != "" {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "honeypot",
|
|
Message: "Bot detected",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ruleTiming validates form submission timing (anti-bot measure)
|
|
// Param format: "min:max" in seconds (e.g., "2:86400" = 2 seconds to 24 hours)
|
|
func ruleTiming(field string, value string, param string) *FieldError {
|
|
if value == "" {
|
|
return nil // Skip if no timestamp provided
|
|
}
|
|
|
|
// Parse the timing parameter "min:max"
|
|
parts := strings.Split(param, ":")
|
|
if len(parts) != 2 {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "timing",
|
|
Param: param,
|
|
Message: "invalid timing parameter format (expected min:max)",
|
|
}
|
|
}
|
|
|
|
minSeconds, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "timing",
|
|
Param: param,
|
|
Message: "invalid minimum timing value",
|
|
}
|
|
}
|
|
|
|
maxSeconds, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "timing",
|
|
Param: param,
|
|
Message: "invalid maximum timing value",
|
|
}
|
|
}
|
|
|
|
// Parse the timestamp value
|
|
timestamp, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "timing",
|
|
Message: "invalid timestamp format",
|
|
}
|
|
}
|
|
|
|
// Calculate time difference
|
|
now := time.Now().Unix()
|
|
timeTaken := now - timestamp
|
|
|
|
// Check if submitted too quickly
|
|
if timeTaken < minSeconds {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "timing",
|
|
Param: param,
|
|
Message: "Form submitted too quickly (bot detected)",
|
|
}
|
|
}
|
|
|
|
// Check if timestamp is invalid (future or too old)
|
|
if timeTaken < 0 || timeTaken > maxSeconds {
|
|
return &FieldError{
|
|
Field: field,
|
|
Tag: "timing",
|
|
Param: param,
|
|
Message: "Invalid timestamp",
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|