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 func() { if err := f.Close(); err != nil { log.Printf("WARNING: Failed to close security log file: %v", err) } }() // 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 }