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:
juanatsap
2025-11-30 13:47:49 +00:00
parent ae430e6ea7
commit f91a24ea9b
26 changed files with 3213 additions and 5 deletions
+352
View File
@@ -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")
)
+524
View File
@@ -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, "&lt;script&gt;") {
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)
}
}