2025-10-20 08:54:21 +01:00
|
|
|
package middleware
|
|
|
|
|
|
2025-10-31 11:06:38 +00:00
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
2025-11-09 14:00:10 +00:00
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
2025-12-06 16:27:12 +00:00
|
|
|
|
2025-12-06 16:31:42 +00:00
|
|
|
c "github.com/juanatsap/cv-site/internal/constants"
|
2025-10-31 11:06:38 +00:00
|
|
|
)
|
2025-10-20 08:54:21 +01:00
|
|
|
|
2025-10-31 11:06:38 +00:00
|
|
|
// SecurityHeaders adds production-grade security headers to responses
|
2025-10-20 08:54:21 +01:00
|
|
|
func SecurityHeaders(next http.Handler) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
// Prevent clickjacking
|
2025-12-06 16:31:42 +00:00
|
|
|
w.Header().Set(c.HeaderXFrameOptions, c.FrameOptionsSameOrigin)
|
2025-10-20 08:54:21 +01:00
|
|
|
|
|
|
|
|
// Prevent MIME type sniffing
|
2025-12-06 16:31:42 +00:00
|
|
|
w.Header().Set(c.HeaderXContentTypeOpts, c.NoSniff)
|
2025-10-20 08:54:21 +01:00
|
|
|
|
2025-10-31 11:06:38 +00:00
|
|
|
// XSS Protection (legacy but still useful for older browsers)
|
2025-12-06 16:31:42 +00:00
|
|
|
w.Header().Set(c.HeaderXXSSProtection, c.XSSProtection)
|
2025-10-20 08:54:21 +01:00
|
|
|
|
2025-10-31 11:06:38 +00:00
|
|
|
// Referrer policy - strict privacy
|
2025-12-06 16:31:42 +00:00
|
|
|
w.Header().Set(c.HeaderReferrerPolicy, c.ReferrerPolicy)
|
2025-10-20 08:54:21 +01:00
|
|
|
|
2025-10-31 11:06:38 +00:00
|
|
|
// Permissions Policy - disable unnecessary features
|
2025-12-06 16:31:42 +00:00
|
|
|
w.Header().Set(c.HeaderPermissionsPolicy,
|
2025-10-31 11:06:38 +00:00
|
|
|
"geolocation=(), microphone=(), camera=(), payment=(), usb=(), "+
|
|
|
|
|
"magnetometer=(), gyroscope=(), accelerometer=()")
|
|
|
|
|
|
|
|
|
|
// Content Security Policy (comprehensive)
|
|
|
|
|
csp := "default-src 'self'; " +
|
2025-12-02 08:29:54 +00:00
|
|
|
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://esm.sh https://matomo.morenorub.io; " +
|
2025-10-31 11:06:38 +00:00
|
|
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
|
|
|
|
"font-src 'self' https://fonts.gstatic.com; " +
|
|
|
|
|
"img-src 'self' data: https:; " +
|
2025-11-20 16:17:56 +00:00
|
|
|
"connect-src 'self' https://api.iconify.design https://matomo.morenorub.io; " +
|
2025-10-31 11:06:38 +00:00
|
|
|
"frame-ancestors 'self'; " +
|
|
|
|
|
"base-uri 'self'; " +
|
|
|
|
|
"form-action 'self'"
|
2025-12-06 16:31:42 +00:00
|
|
|
w.Header().Set(c.HeaderCSP, csp)
|
2025-10-31 11:06:38 +00:00
|
|
|
|
|
|
|
|
// HSTS - only in production with HTTPS
|
2025-12-06 16:31:42 +00:00
|
|
|
if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction {
|
2025-10-31 11:06:38 +00:00
|
|
|
// 1 year max-age, include subdomains
|
2025-12-06 16:31:42 +00:00
|
|
|
w.Header().Set(c.HeaderHSTS, c.HSTSMaxAge)
|
2025-10-31 11:06:38 +00:00
|
|
|
}
|
2025-10-20 08:54:21 +01:00
|
|
|
|
|
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|
2025-11-09 14:00:10 +00:00
|
|
|
|
|
|
|
|
// 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)
|
2025-12-06 16:31:42 +00:00
|
|
|
origin := r.Header.Get(c.HeaderOrigin)
|
2025-11-09 14:00:10 +00:00
|
|
|
if origin != "" {
|
|
|
|
|
if !isAllowedOrigin(origin, allowedOrigins) {
|
|
|
|
|
http.Error(w, "Forbidden: External access not allowed", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check Referer header (for direct requests)
|
2025-12-06 16:31:42 +00:00
|
|
|
referer := r.Header.Get(c.HeaderReferer)
|
2025-11-09 14:00:10 +00:00
|
|
|
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)
|
2025-12-06 16:31:42 +00:00
|
|
|
if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction && r.URL.Path == c.RouteExportPDF {
|
2025-11-09 14:00:10 +00:00
|
|
|
// 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)
|
2025-12-06 16:31:42 +00:00
|
|
|
ip := r.Header.Get(c.HeaderXForwardedFor)
|
2025-11-09 14:00:10 +00:00
|
|
|
if ip == "" {
|
2025-12-06 16:31:42 +00:00
|
|
|
ip = r.Header.Get(c.HeaderXRealIP)
|
2025-11-09 14:00:10 +00:00
|
|
|
}
|
|
|
|
|
if ip == "" {
|
|
|
|
|
ip = strings.Split(r.RemoteAddr, ":")[0]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !rl.allow(ip) {
|
2025-12-06 16:31:42 +00:00
|
|
|
w.Header().Set(c.HeaderRetryAfter, "60")
|
2025-11-09 14:00:10 +00:00
|
|
|
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(1 * 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()
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-12 17:53:24 +00:00
|
|
|
|
|
|
|
|
// 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) {
|
2025-12-06 16:31:42 +00:00
|
|
|
cacheValue := c.CachePublic1Hour
|
|
|
|
|
if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction {
|
|
|
|
|
cacheValue = c.CachePublic1Day
|
2025-11-12 17:53:24 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-06 16:31:42 +00:00
|
|
|
w.Header().Set(c.HeaderCacheControl, cacheValue)
|
2025-11-12 17:53:24 +00:00
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|
2025-12-02 10:46:53 +00:00
|
|
|
|
|
|
|
|
// 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
|
2025-12-06 16:31:42 +00:00
|
|
|
if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction {
|
2025-12-02 10:46:53 +00:00
|
|
|
// Production: 5 minutes cache, but must revalidate
|
2025-12-06 16:31:42 +00:00
|
|
|
w.Header().Set(c.HeaderCacheControl, c.CachePublic5Min)
|
2025-12-02 10:46:53 +00:00
|
|
|
} else {
|
|
|
|
|
// Development: no cache for easier testing
|
2025-12-06 16:31:42 +00:00
|
|
|
w.Header().Set(c.HeaderCacheControl, c.CacheNoStore)
|
2025-12-02 10:46:53 +00:00
|
|
|
}
|
|
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|