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,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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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