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
229 lines
5.9 KiB
Go
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
|
|
}
|