f91a24ea9b
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
525 lines
14 KiB
Go
525 lines
14 KiB
Go
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)
|
|
}
|
|
}
|