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