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
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
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")
|
||||
)
|
||||
@@ -0,0 +1,524 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// EMAIL VALIDATION TESTS
|
||||
// ==============================================================================
|
||||
|
||||
func TestIsValidEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
want bool
|
||||
}{
|
||||
// Valid emails
|
||||
{"Valid standard", "test@example.com", true},
|
||||
{"Valid with subdomain", "user@mail.example.com", true},
|
||||
{"Valid with plus", "user+tag@example.com", true},
|
||||
{"Valid with dot", "first.last@example.com", true},
|
||||
{"Valid with hyphen", "user-name@example.com", true},
|
||||
{"Valid with numbers", "user123@example.com", true},
|
||||
|
||||
// Invalid emails
|
||||
{"Empty", "", false},
|
||||
{"No @", "userexample.com", false},
|
||||
{"Multiple @", "user@@example.com", false},
|
||||
{"No domain", "user@", false},
|
||||
{"No local part", "@example.com", false},
|
||||
{"Spaces", "user @example.com", false},
|
||||
{"Missing TLD", "user@example", false},
|
||||
{"Too long", strings.Repeat("a", 250) + "@example.com", false},
|
||||
{"Special chars", "user<>@example.com", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsValidEmail(tt.email); got != tt.want {
|
||||
t.Errorf("IsValidEmail(%q) = %v, want %v", tt.email, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// EMAIL INJECTION TESTS
|
||||
// ==============================================================================
|
||||
|
||||
func TestContainsEmailInjection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool // true if injection detected
|
||||
}{
|
||||
// Safe inputs
|
||||
{"Normal text", "Hello World", false},
|
||||
{"Name with apostrophe", "O'Connor", false},
|
||||
{"Hyphenated name", "Anne-Marie", false},
|
||||
|
||||
// Injection attempts
|
||||
{"CRLF injection", "Name\r\nBcc: attacker@evil.com", true},
|
||||
{"LF injection", "Name\nBcc: attacker@evil.com", true},
|
||||
{"Content-Type header", "Name\r\nContent-Type: text/html", true},
|
||||
{"BCC header", "bcc: attacker@evil.com", true},
|
||||
{"CC header", "cc: attacker@evil.com", true},
|
||||
{"To header", "to: victim@example.com", true},
|
||||
{"From header", "from: fake@example.com", true},
|
||||
{"Reply-To header", "reply-to: attacker@evil.com", true},
|
||||
{"MIME-Version", "MIME-Version: 1.0", true},
|
||||
{"X-Mailer", "X-Mailer: Evil", true},
|
||||
{"Case insensitive", "BCC: attacker@evil.com", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ContainsEmailInjection(tt.input); got != tt.want {
|
||||
t.Errorf("ContainsEmailInjection(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// NAME VALIDATION TESTS
|
||||
// ==============================================================================
|
||||
|
||||
func TestIsValidName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
// Valid names
|
||||
{"Simple name", "John", true},
|
||||
{"Full name", "John Smith", true},
|
||||
{"Hyphenated", "Anne-Marie", true},
|
||||
{"Apostrophe", "O'Connor", true},
|
||||
{"Multiple words", "Juan José García", true},
|
||||
{"Spanish characters", "José María", true},
|
||||
{"French characters", "François Dubois", true},
|
||||
{"German characters", "Müller", true},
|
||||
|
||||
// Invalid names
|
||||
{"Empty", "", false},
|
||||
{"Numbers", "John123", false},
|
||||
{"Special chars", "John@Smith", false},
|
||||
{"HTML tags", "<script>alert(1)</script>", false},
|
||||
{"Email", "john@example.com", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsValidName(tt.input); got != tt.want {
|
||||
t.Errorf("IsValidName(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// SUBJECT VALIDATION TESTS
|
||||
// ==============================================================================
|
||||
|
||||
func TestIsValidSubject(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
// Valid subjects
|
||||
{"Simple", "Hello", true},
|
||||
{"With spaces", "Hello World", true},
|
||||
{"With punctuation", "Question about your services!", true},
|
||||
{"With numbers", "Order #12345", true},
|
||||
{"Complex", "Re: Your inquiry (urgent)", true},
|
||||
|
||||
// Invalid subjects
|
||||
{"Empty", "", false},
|
||||
{"HTML tags", "<script>alert(1)</script>", false},
|
||||
{"Email injection", "Subject\nBcc: evil@example.com", false},
|
||||
{"Special chars", "Subject $ % ^ &", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsValidSubject(tt.input); got != tt.want {
|
||||
t.Errorf("IsValidSubject(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// COMPANY VALIDATION TESTS
|
||||
// ==============================================================================
|
||||
|
||||
func TestIsValidCompany(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
// Valid company names
|
||||
{"Empty (optional)", "", true},
|
||||
{"Simple", "Acme Corp", true},
|
||||
{"With punctuation", "Smith & Sons, Inc.", true},
|
||||
{"With hyphen", "Coca-Cola", true},
|
||||
{"With parentheses", "Example (Spain)", true},
|
||||
|
||||
// Invalid company names
|
||||
{"HTML tags", "<script>", false},
|
||||
{"Email injection", "Company\nBcc: evil@example.com", false},
|
||||
{"Special chars", "Company$$$", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsValidCompany(tt.input); got != tt.want {
|
||||
t.Errorf("IsValidCompany(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// SANITIZATION TESTS
|
||||
// ==============================================================================
|
||||
|
||||
func TestSanitizeContactForm(t *testing.T) {
|
||||
req := &ContactFormRequest{
|
||||
Name: " John \n Smith ",
|
||||
Email: " john@example.com \r\n",
|
||||
Company: " Acme Corp ",
|
||||
Subject: " Test Subject ",
|
||||
Message: "<script>alert('XSS')</script>\n\n\nHello World\n\n\n\n",
|
||||
}
|
||||
|
||||
SanitizeContactForm(req)
|
||||
|
||||
// Check whitespace trimmed
|
||||
if req.Name != "John Smith" {
|
||||
t.Errorf("Name not properly sanitized: %q", req.Name)
|
||||
}
|
||||
|
||||
// Check newlines removed from headers
|
||||
if strings.Contains(req.Email, "\n") || strings.Contains(req.Email, "\r") {
|
||||
t.Errorf("Email still contains newlines: %q", req.Email)
|
||||
}
|
||||
|
||||
// Check HTML escaped
|
||||
if !strings.Contains(req.Message, "<script>") {
|
||||
t.Errorf("Message not properly HTML escaped: %q", req.Message)
|
||||
}
|
||||
|
||||
// Check whitespace normalized
|
||||
if strings.Contains(req.Message, " ") {
|
||||
t.Errorf("Message whitespace not normalized: %q", req.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// FULL VALIDATION TESTS
|
||||
// ==============================================================================
|
||||
|
||||
func TestValidateContactForm(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *ContactFormRequest
|
||||
wantError bool
|
||||
errorField string
|
||||
}{
|
||||
{
|
||||
name: "Valid form",
|
||||
req: &ContactFormRequest{
|
||||
Name: "John Smith",
|
||||
Email: "john@example.com",
|
||||
Company: "Acme Corp",
|
||||
Subject: "Inquiry",
|
||||
Message: "Hello, I have a question.",
|
||||
Honeypot: "",
|
||||
Timestamp: time.Now().Unix() - 10, // 10 seconds ago
|
||||
},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Bot detected - honeypot filled",
|
||||
req: &ContactFormRequest{
|
||||
Name: "Bot",
|
||||
Email: "bot@example.com",
|
||||
Subject: "Spam",
|
||||
Message: "Spam message",
|
||||
Honeypot: "http://evil.com", // Bot filled this
|
||||
},
|
||||
wantError: true,
|
||||
errorField: "website",
|
||||
},
|
||||
{
|
||||
name: "Bot detected - submitted too fast",
|
||||
req: &ContactFormRequest{
|
||||
Name: "John",
|
||||
Email: "john@example.com",
|
||||
Subject: "Test",
|
||||
Message: "Test",
|
||||
Timestamp: time.Now().Unix(), // Just now (< 2 seconds)
|
||||
},
|
||||
wantError: true,
|
||||
errorField: "timestamp",
|
||||
},
|
||||
{
|
||||
name: "Missing required field - name",
|
||||
req: &ContactFormRequest{
|
||||
Name: "",
|
||||
Email: "john@example.com",
|
||||
Subject: "Test",
|
||||
Message: "Test",
|
||||
},
|
||||
wantError: true,
|
||||
errorField: "name",
|
||||
},
|
||||
{
|
||||
name: "Invalid email format",
|
||||
req: &ContactFormRequest{
|
||||
Name: "John",
|
||||
Email: "invalid-email",
|
||||
Subject: "Test",
|
||||
Message: "Test",
|
||||
},
|
||||
wantError: true,
|
||||
errorField: "email",
|
||||
},
|
||||
{
|
||||
name: "Email injection attempt",
|
||||
req: &ContactFormRequest{
|
||||
Name: "Evil\nBcc: attacker@evil.com",
|
||||
Email: "evil@example.com",
|
||||
Subject: "Test",
|
||||
Message: "Test",
|
||||
},
|
||||
wantError: true,
|
||||
errorField: "name",
|
||||
},
|
||||
{
|
||||
name: "Name too long",
|
||||
req: &ContactFormRequest{
|
||||
Name: strings.Repeat("a", 101),
|
||||
Email: "john@example.com",
|
||||
Subject: "Test",
|
||||
Message: "Test",
|
||||
},
|
||||
wantError: true,
|
||||
errorField: "name",
|
||||
},
|
||||
{
|
||||
name: "Subject too long",
|
||||
req: &ContactFormRequest{
|
||||
Name: "John",
|
||||
Email: "john@example.com",
|
||||
Subject: strings.Repeat("a", 201),
|
||||
Message: "Test",
|
||||
},
|
||||
wantError: true,
|
||||
errorField: "subject",
|
||||
},
|
||||
{
|
||||
name: "Message too long",
|
||||
req: &ContactFormRequest{
|
||||
Name: "John",
|
||||
Email: "john@example.com",
|
||||
Subject: "Test",
|
||||
Message: strings.Repeat("a", 5001),
|
||||
},
|
||||
wantError: true,
|
||||
errorField: "message",
|
||||
},
|
||||
{
|
||||
name: "Invalid name characters",
|
||||
req: &ContactFormRequest{
|
||||
Name: "John123",
|
||||
Email: "john@example.com",
|
||||
Subject: "Test",
|
||||
Message: "Test",
|
||||
},
|
||||
wantError: true,
|
||||
errorField: "name",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateContactForm(tt.req)
|
||||
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
return
|
||||
}
|
||||
|
||||
// Check error is ValidationError
|
||||
valErr, ok := err.(*ValidationError)
|
||||
if !ok {
|
||||
t.Errorf("Expected ValidationError, got %T", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check correct field
|
||||
if valErr.Field != tt.errorField {
|
||||
t.Errorf("Expected error field %q, got %q", tt.errorField, valErr.Field)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// SECURITY TESTS (Attack Simulations)
|
||||
// ==============================================================================
|
||||
|
||||
func TestSecurityAttacks(t *testing.T) {
|
||||
attacks := []struct {
|
||||
name string
|
||||
field string
|
||||
value string
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "SQL Injection in name",
|
||||
field: "name",
|
||||
value: "Robert'; DROP TABLE users; --",
|
||||
reason: "Should reject SQL injection attempts",
|
||||
},
|
||||
// NOTE: XSS in message is allowed during validation - it's escaped during sanitization
|
||||
// This is intentional - we don't reject valid messages that happen to contain < or >
|
||||
// The sanitization step handles HTML escaping before sending email
|
||||
{
|
||||
name: "Email header injection",
|
||||
field: "email",
|
||||
value: "test@example.com\nBcc: attacker@evil.com",
|
||||
reason: "Should block email header injection",
|
||||
},
|
||||
{
|
||||
name: "Command injection",
|
||||
field: "name",
|
||||
value: "Test; rm -rf /",
|
||||
reason: "Should block command injection attempts",
|
||||
},
|
||||
{
|
||||
name: "Path traversal",
|
||||
field: "subject",
|
||||
value: "../../../etc/passwd",
|
||||
reason: "Should reject path traversal attempts",
|
||||
},
|
||||
}
|
||||
|
||||
for _, attack := range attacks {
|
||||
t.Run(attack.name, func(t *testing.T) {
|
||||
req := &ContactFormRequest{
|
||||
Name: "John Smith",
|
||||
Email: "john@example.com",
|
||||
Subject: "Test",
|
||||
Message: "Test",
|
||||
Timestamp: time.Now().Unix() - 10,
|
||||
}
|
||||
|
||||
// Inject attack into specified field
|
||||
switch attack.field {
|
||||
case "name":
|
||||
req.Name = attack.value
|
||||
case "email":
|
||||
req.Email = attack.value
|
||||
case "subject":
|
||||
req.Subject = attack.value
|
||||
case "message":
|
||||
req.Message = attack.value
|
||||
}
|
||||
|
||||
err := ValidateContactForm(req)
|
||||
|
||||
if err == nil {
|
||||
t.Errorf("%s: Expected validation to fail, but it passed", attack.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// NORMALIZATION TESTS
|
||||
// ==============================================================================
|
||||
|
||||
func TestRemoveNewlines(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"No newlines", "No newlines"},
|
||||
{"With\nnewline", "Withnewline"},
|
||||
{"With\r\nCRLF", "WithCRLF"},
|
||||
{"Multiple\n\n\nnewlines", "Multiplenewlines"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := removeNewlines(tt.input); got != tt.want {
|
||||
t.Errorf("removeNewlines(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWhitespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Normal text", "Normal text"},
|
||||
{"Multiple spaces", "Multiple spaces"},
|
||||
{"Multiple\n\n\n\nnewlines", "Multiple\n\nnewlines"},
|
||||
{" Leading and trailing ", "Leading and trailing"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := normalizeWhitespace(tt.input); got != tt.want {
|
||||
t.Errorf("normalizeWhitespace(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// BENCHMARK TESTS
|
||||
// ==============================================================================
|
||||
|
||||
func BenchmarkIsValidEmail(b *testing.B) {
|
||||
email := "test@example.com"
|
||||
for i := 0; i < b.N; i++ {
|
||||
IsValidEmail(email)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkContainsEmailInjection(b *testing.B) {
|
||||
text := "Normal text without injection"
|
||||
for i := 0; i < b.N; i++ {
|
||||
ContainsEmailInjection(text)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidateContactForm(b *testing.B) {
|
||||
req := &ContactFormRequest{
|
||||
Name: "John Smith",
|
||||
Email: "john@example.com",
|
||||
Company: "Acme Corp",
|
||||
Subject: "Inquiry",
|
||||
Message: "Hello, I have a question.",
|
||||
Timestamp: time.Now().Unix() - 10,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ValidateContactForm(req)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user