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
+120
View File
@@ -0,0 +1,120 @@
package middleware
import (
"log"
"net/http"
"strings"
)
const (
// Custom header that browser JavaScript must set
browserHeaderName = "X-Requested-With"
browserHeaderValue = "XMLHttpRequest"
)
// BrowserOnly restricts endpoint access to browser requests only
// Blocks curl, Postman, and other HTTP clients
// Requires:
// 1. User-Agent header (not curl/wget/etc)
// 2. Referer or Origin header from same domain
// 3. Custom header set by JavaScript (X-Requested-With: XMLHttpRequest)
func BrowserOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check 1: User-Agent validation
userAgent := r.Header.Get("User-Agent")
if userAgent == "" || isBotUserAgent(userAgent) {
log.Printf("SECURITY: Blocked non-browser User-Agent from IP %s: %s", getRequestIP(r), userAgent)
http.Error(w, "Forbidden: Browser access only", http.StatusForbidden)
return
}
// Check 2: Require Referer or Origin header
referer := r.Header.Get("Referer")
origin := r.Header.Get("Origin")
if referer == "" && origin == "" {
log.Printf("SECURITY: Blocked request without Referer/Origin from IP %s", getRequestIP(r))
http.Error(w, "Forbidden: Browser access only", http.StatusForbidden)
return
}
// Check 3: Custom header validation (set by JavaScript)
// For HTMX requests, check HX-Request header
// For fetch/XMLHttpRequest, check X-Requested-With header
hasHTMXHeader := r.Header.Get("HX-Request") == "true"
hasXMLHTTPHeader := r.Header.Get(browserHeaderName) == browserHeaderValue
hasCustomBrowserHeader := r.Header.Get("X-Browser-Request") == "true"
if !hasHTMXHeader && !hasXMLHTTPHeader && !hasCustomBrowserHeader {
log.Printf("SECURITY: Blocked request without browser headers from IP %s", getRequestIP(r))
http.Error(w, "Forbidden: Browser access only", http.StatusForbidden)
return
}
// All checks passed
next.ServeHTTP(w, r)
})
}
// isBotUserAgent checks if the User-Agent is from a known HTTP client/bot
func isBotUserAgent(ua string) bool {
ua = strings.ToLower(ua)
// Known HTTP clients and tools
blockedAgents := []string{
"curl",
"wget",
"postman",
"insomnia",
"httpie",
"python-requests",
"python-urllib",
"java",
"okhttp",
"go-http-client",
"axios", // Node.js axios without proper browser headers
"node-fetch",
"apache-httpclient",
"libwww-perl",
"php",
"ruby",
"scrapy",
"bot",
"crawler",
"spider",
}
for _, blocked := range blockedAgents {
if strings.Contains(ua, blocked) {
return true
}
}
return false
}
// getRequestIP extracts the client IP from the request
func getRequestIP(r *http.Request) string {
// Try X-Forwarded-For first (for proxies/load balancers)
ip := r.Header.Get("X-Forwarded-For")
if ip != "" {
// Take first IP if multiple
ips := strings.Split(ip, ",")
return strings.TrimSpace(ips[0])
}
// Try X-Real-IP
ip = r.Header.Get("X-Real-IP")
if ip != "" {
return ip
}
// Fallback to RemoteAddr
ip = r.RemoteAddr
// Remove port
if idx := strings.LastIndex(ip, ":"); idx != -1 {
ip = ip[:idx]
}
return ip
}
+131
View File
@@ -0,0 +1,131 @@
package middleware
import (
"net/http"
"strings"
"sync"
"time"
)
// contactRateLimitEntry tracks rate limiting for contact form per IP
type contactRateLimitEntry struct {
count int
resetTime time.Time
}
// ContactRateLimiter provides rate limiting specifically for contact form
// Allows 5 submissions per hour per IP address
type ContactRateLimiter struct {
mu sync.RWMutex
clients map[string]*contactRateLimitEntry
}
// NewContactRateLimiter creates a new contact form rate limiter
// Default: 5 requests per hour per IP
func NewContactRateLimiter() *ContactRateLimiter {
rl := &ContactRateLimiter{
clients: make(map[string]*contactRateLimitEntry),
}
// Cleanup expired entries every 10 minutes
go rl.cleanup()
return rl
}
// Middleware returns rate limiting middleware for contact form
func (rl *ContactRateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get client IP (handle X-Forwarded-For for proxies)
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.Header.Get("X-Real-IP")
}
if ip == "" {
ip = strings.Split(r.RemoteAddr, ":")[0]
}
// Extract first IP if multiple IPs in X-Forwarded-For
if strings.Contains(ip, ",") {
ip = strings.TrimSpace(strings.Split(ip, ",")[0])
}
if !rl.allow(ip) {
// Check if HTMX request
isHTMX := r.Header.Get("HX-Request") != ""
if isHTMX {
// Return HTMX-friendly error
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`<div class="alert alert-error">
<h3>Too Many Requests</h3>
<p>You've submitted too many contact forms. Please wait an hour before trying again.</p>
</div>`))
} else {
w.Header().Set("Retry-After", "3600") // 1 hour
http.Error(w, "Too many contact form submissions. Please try again in an hour.", http.StatusTooManyRequests)
}
return
}
next.ServeHTTP(w, r)
})
}
// allow checks if the request is allowed based on rate limit
// Limit: 5 submissions per hour
func (rl *ContactRateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
limit := 5
window := 1 * time.Hour
entry, exists := rl.clients[ip]
if !exists || now.After(entry.resetTime) {
// New client or window expired
rl.clients[ip] = &contactRateLimitEntry{
count: 1,
resetTime: now.Add(window),
}
return true
}
if entry.count >= limit {
return false
}
entry.count++
return true
}
// cleanup removes expired entries periodically
func (rl *ContactRateLimiter) cleanup() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
now := time.Now()
for ip, entry := range rl.clients {
if now.After(entry.resetTime) {
delete(rl.clients, ip)
}
}
rl.mu.Unlock()
}
}
// GetStats returns current rate limit statistics (for monitoring/debugging)
func (rl *ContactRateLimiter) GetStats() map[string]interface{} {
rl.mu.RLock()
defer rl.mu.RUnlock()
return map[string]interface{}{
"total_clients": len(rl.clients),
"limit": 5,
"window": "1 hour",
}
}
+200
View File
@@ -0,0 +1,200 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"net/http"
"sync"
"time"
)
const (
csrfTokenLength = 32
csrfCookieName = "csrf_token"
csrfFormField = "csrf_token"
csrfTokenTTL = 24 * time.Hour
)
// csrfTokenEntry stores token with expiration
type csrfTokenEntry struct {
token string
expiresAt time.Time
}
// CSRFProtection provides CSRF token generation and validation
type CSRFProtection struct {
mu sync.RWMutex
tokens map[string]*csrfTokenEntry // map[token]entry
}
// NewCSRFProtection creates a new CSRF protection instance
func NewCSRFProtection() *CSRFProtection {
csrf := &CSRFProtection{
tokens: make(map[string]*csrfTokenEntry),
}
// Cleanup expired tokens every hour
go csrf.cleanup()
return csrf
}
// Middleware provides CSRF protection for state-changing operations
// GET requests: Generate and set CSRF token
// POST/PUT/DELETE: Validate CSRF token
func (c *CSRFProtection) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only validate on state-changing methods
if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete {
if !c.validateToken(r) {
log.Printf("SECURITY: CSRF validation failed from IP %s", getClientIP(r))
// Check if HTMX request
isHTMX := r.Header.Get("HX-Request") != ""
if isHTMX {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`<div class="alert alert-error">
<h3>Security Error</h3>
<p>Invalid security token. Please refresh the page and try again.</p>
</div>`))
} else {
http.Error(w, "CSRF validation failed", http.StatusForbidden)
}
return
}
}
next.ServeHTTP(w, r)
})
}
// generateToken creates a new CSRF token
func (c *CSRFProtection) generateToken() (string, error) {
bytes := make([]byte, csrfTokenLength)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
token := base64.URLEncoding.EncodeToString(bytes)
// Store token with expiration
c.mu.Lock()
c.tokens[token] = &csrfTokenEntry{
token: token,
expiresAt: time.Now().Add(csrfTokenTTL),
}
c.mu.Unlock()
return token, nil
}
// GetToken retrieves or generates a CSRF token for the request
// This should be called when rendering forms
func (c *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (string, error) {
// Check if token exists in cookie
cookie, err := r.Cookie(csrfCookieName)
if err == nil && cookie.Value != "" {
// Validate existing token
c.mu.RLock()
entry, exists := c.tokens[cookie.Value]
c.mu.RUnlock()
if exists && time.Now().Before(entry.expiresAt) {
// Token is valid, return it
return cookie.Value, nil
}
}
// Generate new token
token, err := c.generateToken()
if err != nil {
return "", fmt.Errorf("failed to generate CSRF token: %w", err)
}
// Set cookie
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil, // Only set Secure flag if using HTTPS
SameSite: http.SameSiteStrictMode,
MaxAge: int(csrfTokenTTL.Seconds()),
})
return token, nil
}
// validateToken validates the CSRF token from the request
func (c *CSRFProtection) validateToken(r *http.Request) bool {
// Get token from form
var formToken string
// Try form value first
if err := r.ParseForm(); err == nil {
formToken = r.FormValue(csrfFormField)
}
// If not in form, try header (for AJAX requests)
if formToken == "" {
formToken = r.Header.Get("X-CSRF-Token")
}
if formToken == "" {
log.Printf("CSRF: No token in request")
return false
}
// Get token from cookie
cookie, err := r.Cookie(csrfCookieName)
if err != nil || cookie.Value == "" {
log.Printf("CSRF: No token in cookie")
return false
}
// Tokens must match
if formToken != cookie.Value {
log.Printf("CSRF: Token mismatch")
return false
}
// Validate token exists and is not expired
c.mu.RLock()
entry, exists := c.tokens[formToken]
c.mu.RUnlock()
if !exists {
log.Printf("CSRF: Token not found in store")
return false
}
if time.Now().After(entry.expiresAt) {
log.Printf("CSRF: Token expired")
return false
}
return true
}
// cleanup removes expired tokens periodically
func (c *CSRFProtection) cleanup() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for token, entry := range c.tokens {
if now.After(entry.expiresAt) {
delete(c.tokens, token)
}
}
c.mu.Unlock()
}
}
// Note: getClientIP is defined in security_logger.go
+228
View File
@@ -0,0 +1,228 @@
package middleware
import (
"encoding/json"
"log"
"net/http"
"os"
"strings"
"time"
)
// SecurityEvent represents a security-related event
type SecurityEvent struct {
Timestamp time.Time `json:"timestamp"`
EventType string `json:"event_type"`
Severity string `json:"severity"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Method string `json:"method"`
Path string `json:"path"`
Details string `json:"details"`
}
// EventSeverity levels
const (
SeverityCritical = "CRITICAL"
SeverityHigh = "HIGH"
SeverityMedium = "MEDIUM"
SeverityLow = "LOW"
SeverityInfo = "INFO"
)
// Event types
const (
EventBlocked = "BLOCKED"
EventCSRFViolation = "CSRF_VIOLATION"
EventOriginViolation = "ORIGIN_VIOLATION"
EventRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
EventValidationFailed = "VALIDATION_FAILED"
EventSuspiciousUserAgent = "SUSPICIOUS_USER_AGENT"
EventContactFormSent = "CONTACT_FORM_SENT"
EventContactFormFailed = "CONTACT_FORM_FAILED"
EventPDFGenerated = "PDF_GENERATED"
EventPDFGenerationFailed = "PDF_GENERATION_FAILED"
EventEmailSendFailed = "EMAIL_SEND_FAILED"
EventBotDetected = "BOT_DETECTED"
)
// LogSecurityEvent logs a security event in structured JSON format
func LogSecurityEvent(eventType string, r *http.Request, details string) {
severity := getSeverity(eventType)
event := SecurityEvent{
Timestamp: time.Now(),
EventType: eventType,
Severity: severity,
IP: getClientIP(r),
UserAgent: r.Header.Get("User-Agent"),
Method: r.Method,
Path: r.URL.Path,
Details: details,
}
// JSON format for easy parsing by SIEM systems
eventJSON, err := json.Marshal(event)
if err != nil {
log.Printf("ERROR: Failed to marshal security event: %v", err)
return
}
// Log to stdout (captured by systemd/Docker)
log.Printf("[SECURITY] %s", eventJSON)
// Also log to separate security log file in production
if os.Getenv("GO_ENV") == "production" {
logToSecurityFile(eventJSON)
}
}
// getSeverity determines the severity level based on event type
func getSeverity(eventType string) string {
switch eventType {
case EventBlocked, EventCSRFViolation, EventOriginViolation:
return SeverityHigh
case EventRateLimitExceeded, EventValidationFailed, EventSuspiciousUserAgent,
EventContactFormFailed, EventPDFGenerationFailed, EventEmailSendFailed:
return SeverityMedium
case EventBotDetected:
return SeverityLow
case EventContactFormSent, EventPDFGenerated:
return SeverityInfo
default:
return SeverityLow
}
}
// getClientIP extracts the real client IP from request headers
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header (proxy/load balancer)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take first IP from comma-separated list
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
}
// Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// Fallback to RemoteAddr (remove port)
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx != -1 {
ip = ip[:idx]
}
return ip
}
// logToSecurityFile appends security events to a dedicated log file
func logToSecurityFile(eventJSON []byte) {
// Create log directory if it doesn't exist
logDir := "/var/log/cv-app"
if err := os.MkdirAll(logDir, 0755); err != nil {
log.Printf("WARNING: Failed to create log directory: %v", err)
return
}
// Open security log file (append mode)
logPath := logDir + "/security.log"
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("WARNING: Failed to open security log file: %v", err)
return
}
defer f.Close()
// Write event with newline
if _, err := f.Write(eventJSON); err != nil {
log.Printf("WARNING: Failed to write to security log: %v", err)
return
}
if _, err := f.WriteString("\n"); err != nil {
log.Printf("WARNING: Failed to write newline to security log: %v", err)
}
}
// SecurityLogger middleware logs all requests with security context
func SecurityLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap response writer to capture status
wrapped := &responseWriter{
ResponseWriter: w,
status: http.StatusOK,
}
// Process request
next.ServeHTTP(wrapped, r)
// Log security-relevant requests
duration := time.Since(start)
// Log high-value endpoints
if isSecurityRelevantPath(r.URL.Path) {
details := map[string]interface{}{
"status": wrapped.status,
"duration_ms": duration.Milliseconds(),
"bytes": wrapped.written,
}
detailsJSON, _ := json.Marshal(details)
event := SecurityEvent{
Timestamp: time.Now(),
EventType: "REQUEST",
Severity: SeverityInfo,
IP: getClientIP(r),
UserAgent: r.Header.Get("User-Agent"),
Method: r.Method,
Path: r.URL.Path,
Details: string(detailsJSON),
}
eventJSON, _ := json.Marshal(event)
log.Printf("[SECURITY] %s", eventJSON)
}
// Log errors and security failures
if wrapped.status >= 400 {
severity := SeverityLow
if wrapped.status == 403 || wrapped.status == 429 {
severity = SeverityMedium
}
event := SecurityEvent{
Timestamp: time.Now(),
EventType: "HTTP_ERROR",
Severity: severity,
IP: getClientIP(r),
UserAgent: r.Header.Get("User-Agent"),
Method: r.Method,
Path: r.URL.Path,
Details: http.StatusText(wrapped.status),
}
eventJSON, _ := json.Marshal(event)
log.Printf("[SECURITY] %s", eventJSON)
}
})
}
// isSecurityRelevantPath determines if a path should be logged for security
func isSecurityRelevantPath(path string) bool {
securityPaths := []string{
"/api/contact",
"/export/pdf",
"/toggle/",
"/switch-language",
}
for _, sp := range securityPaths {
if strings.HasPrefix(path, sp) {
return true
}
}
return false
}