Files
cv-site/internal/middleware/security_logger.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

229 lines
5.9 KiB
Go

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
}