package middleware import ( "net/http" "os" "strings" "sync" "time" c "github.com/juanatsap/cv-site/internal/constants" ) // SecurityHeaders adds production-grade security headers to responses func SecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Prevent clickjacking w.Header().Set(c.HeaderXFrameOptions, c.FrameOptionsSameOrigin) // Prevent MIME type sniffing w.Header().Set(c.HeaderXContentTypeOpts, c.NoSniff) // XSS Protection (legacy but still useful for older browsers) w.Header().Set(c.HeaderXXSSProtection, c.XSSProtection) // Referrer policy - strict privacy w.Header().Set(c.HeaderReferrerPolicy, c.ReferrerPolicy) // Permissions Policy - disable unnecessary features w.Header().Set(c.HeaderPermissionsPolicy, "geolocation=(), microphone=(), camera=(), payment=(), usb=(), "+ "magnetometer=(), gyroscope=(), accelerometer=()") // Content Security Policy (comprehensive) csp := "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://esm.sh https://matomo.txeo.club; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data: https:; " + "connect-src 'self' https://api.iconify.design https://matomo.txeo.club; " + "frame-ancestors 'self'; " + "base-uri 'self'; " + "form-action 'self'" w.Header().Set(c.HeaderCSP, csp) // HSTS - only in production with HTTPS if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction { // 1 year max-age, include subdomains w.Header().Set(c.HeaderHSTS, c.HSTSMaxAge) } next.ServeHTTP(w, r) }) } // OriginChecker restricts API access to requests from allowed origins only // Prevents external sites from hotlinking/accessing resource-intensive endpoints func OriginChecker(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Get allowed domains from environment (comma-separated) // Example: ALLOWED_ORIGINS="yourdomain.com,www.yourdomain.com" allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS") // If empty, add "juan.andres.morenorub.io", as it is the domain of the CV if allowedOriginsEnv == "" { allowedOriginsEnv = "juan.andres.morenorub.io" } // Default to localhost for development allowedOrigins := []string{"localhost", "127.0.0.1"} if allowedOriginsEnv != "" { customOrigins := strings.Split(allowedOriginsEnv, ",") for _, origin := range customOrigins { allowedOrigins = append(allowedOrigins, strings.TrimSpace(origin)) } } // Check Origin header (for CORS requests) origin := r.Header.Get(c.HeaderOrigin) if origin != "" { if !isAllowedOrigin(origin, allowedOrigins) { http.Error(w, "Forbidden: External access not allowed", http.StatusForbidden) return } } // Check Referer header (for direct requests) referer := r.Header.Get(c.HeaderReferer) if referer != "" { if !isAllowedOrigin(referer, allowedOrigins) { http.Error(w, "Forbidden: External access not allowed", http.StatusForbidden) return } } // Allow if no Origin/Referer (direct browser access) // This allows your own site visitors to access the endpoint if origin == "" && referer == "" { // For production, you might want to be stricter here // For now, allow it (users can bookmark /export/pdf directly) if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction && r.URL.Path == c.RouteExportPDF { // In production, require at least a referer for PDF endpoint http.Error(w, "Forbidden: Direct access not allowed", http.StatusForbidden) return } } next.ServeHTTP(w, r) }) } // isAllowedOrigin checks if the origin/referer matches allowed domains func isAllowedOrigin(originURL string, allowedOrigins []string) bool { originURL = strings.TrimSpace(originURL) originURL = strings.TrimPrefix(originURL, "http://") originURL = strings.TrimPrefix(originURL, "https://") // Extract domain from URL (remove path) parts := strings.Split(originURL, "/") domain := parts[0] // Remove port if present domain = strings.Split(domain, ":")[0] for _, allowed := range allowedOrigins { if strings.EqualFold(domain, allowed) { return true } } return false } // rateLimitEntry tracks rate limiting per IP type rateLimitEntry struct { count int resetTime time.Time } // RateLimiter provides simple in-memory rate limiting type RateLimiter struct { mu sync.RWMutex clients map[string]*rateLimitEntry limit int // requests allowed window time.Duration // time window } // NewRateLimiter creates a new rate limiter func NewRateLimiter(limit int, window time.Duration) *RateLimiter { rl := &RateLimiter{ clients: make(map[string]*rateLimitEntry), limit: limit, window: window, } // Cleanup expired entries every minute go rl.cleanup() return rl } // Middleware returns rate limiting middleware func (rl *RateLimiter) 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(c.HeaderXForwardedFor) if ip == "" { ip = r.Header.Get(c.HeaderXRealIP) } if ip == "" { ip = strings.Split(r.RemoteAddr, ":")[0] } if !rl.allow(ip) { w.Header().Set(c.HeaderRetryAfter, "60") http.Error(w, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) } // allow checks if the request is allowed based on rate limit func (rl *RateLimiter) allow(ip string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() entry, exists := rl.clients[ip] if !exists || now.After(entry.resetTime) { // New client or window expired rl.clients[ip] = &rateLimitEntry{ count: 1, resetTime: now.Add(rl.window), } return true } if entry.count >= rl.limit { return false } entry.count++ return true } // cleanup removes expired entries periodically func (rl *RateLimiter) cleanup() { ticker := time.NewTicker(c.RateLimitGeneralCleanupPeriod) 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() } } // CacheControl adds cache headers to static files // 1 hour in development, 1 day in production func CacheControl(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cacheValue := c.CachePublic1Hour if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction { cacheValue = c.CachePublic1Day } w.Header().Set(c.HeaderCacheControl, cacheValue) next.ServeHTTP(w, r) }) } // DynamicCacheControl adds appropriate cache headers for dynamic HTML pages // Short cache with must-revalidate for dynamic content func DynamicCacheControl(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // For dynamic HTML pages: short cache, must revalidate // This improves performance while ensuring fresh content if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction { // Production: 5 minutes cache, but must revalidate w.Header().Set(c.HeaderCacheControl, c.CachePublic5Min) } else { // Development: no cache for easier testing w.Header().Set(c.HeaderCacheControl, c.CacheNoStore) } next.ServeHTTP(w, r) }) }