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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user