Files
cv-site/internal/validation/contact.go
T
juanatsap f91a24ea9b feat: Add plain text CV endpoint and contact form with security
Plain text endpoint:
- Add /text route for plain text CV (for curl/AI crawlers)
- Use k3a/html2text library for HTML-to-text conversion
- Add Plain Text button to hamburger menu with UI translations

Contact form feature:
- Add ContactHandler with proper email service integration
- Add CSRF protection middleware
- Add rate limiting (5 submissions/hour per IP)
- Add honeypot and timing-based bot protection
- Add input validation with detailed error messages
- Add security logging middleware
- Add browser-only middleware for API protection

Code quality:
- Fix all golangci-lint errcheck warnings for w.Write calls
- Remove duplicate getClientIP functions
- Wire up ContactHandler in routes.Setup
2025-11-30 13:47:49 +00:00

353 lines
8.9 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"`
Email string `json:"email"`
Company string `json:"company"`
Subject string `json:"subject"`
Message string `json:"message"`
Honeypot string `json:"website"` // Should always be empty (bot trap)
Timestamp int64 `json:"timestamp"` // 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")
)