diff --git a/.env.example b/.env.example index 8caece3..3b9f2d9 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,17 @@ DATA_DIR=data READ_TIMEOUT=15 WRITE_TIMEOUT=15 +# Security Configuration +# Allowed origins for API access (comma-separated domains) +# Prevents external sites from accessing your API/PDF endpoint +# Leave empty for development (allows localhost) +# Example for production: ALLOWED_ORIGINS=yourdomain.com,www.yourdomain.com +ALLOWED_ORIGINS= + # Production Settings # Uncomment for production: # GO_ENV=production # TEMPLATE_HOT_RELOAD=false # READ_TIMEOUT=30 # WRITE_TIMEOUT=30 +# ALLOWED_ORIGINS=yourdomain.com,www.yourdomain.com diff --git a/doc/API-PROTECTION.md b/doc/API-PROTECTION.md new file mode 100644 index 0000000..256b51b --- /dev/null +++ b/doc/API-PROTECTION.md @@ -0,0 +1,452 @@ +# API Protection & Security + +**Protection against external access and DDoS attacks on resource-intensive endpoints.** + +--- + +## Overview + +The CV website implements multiple layers of protection to prevent external sites from accessing the API and to protect against DDoS attacks on resource-intensive endpoints like PDF generation. + +### Protection Layers + +1. **Origin Checking** - Only allows requests from your domain +2. **Rate Limiting** - Prevents abuse even from allowed origins +3. **Production Restrictions** - Stricter rules in production environments + +--- + +## 1. Origin Checking + +**File:** `internal/middleware/security.go` (`OriginChecker` middleware) + +### How It Works + +The origin checker examines incoming HTTP requests and validates them against a whitelist of allowed domains. It checks two headers: + +1. **Origin Header** - Set by browsers for CORS requests +2. **Referer Header** - Set by browsers for navigation requests + +### Configuration + +**Environment Variable:** `ALLOWED_ORIGINS` + +```bash +# Development (default) +ALLOWED_ORIGINS= + +# Production +ALLOWED_ORIGINS=yourdomain.com,www.yourdomain.com +``` + +### Behavior + +| Environment | No Header | Localhost | Your Domain | External Domain | +|-------------|-----------|-----------|-------------|-----------------| +| **Development** | ✅ Allowed | ✅ Allowed | ✅ Allowed | ❌ Blocked | +| **Production** | ❌ Blocked (PDF) | ✅ Allowed | ✅ Allowed | ❌ Blocked | + +**Production PDF Endpoint:** +- Requires `Origin` or `Referer` header +- Blocks direct URL access without headers +- Prevents bookmarking and external hotlinking + +### Example Responses + +**Allowed Request:** +```bash +curl -H "Referer: https://yourdomain.com/" http://localhost:1999/export/pdf?lang=en +# Status: 200 OK +# PDF file downloaded +``` + +**Blocked Request (External Domain):** +```bash +curl -H "Referer: https://externaldomain.com/" http://localhost:1999/export/pdf?lang=en +# Status: 403 Forbidden +# Response: Forbidden: External access not allowed +``` + +**Blocked Request (Production, No Headers):** +```bash +# In production with GO_ENV=production +curl http://yourdomain.com/export/pdf?lang=en +# Status: 403 Forbidden +# Response: Forbidden: Direct access not allowed +``` + +--- + +## 2. Rate Limiting + +**File:** `internal/middleware/security.go` (`RateLimiter`) + +### How It Works + +The rate limiter tracks requests per IP address and enforces limits on resource-intensive endpoints. + +**Current Configuration:** +- **Limit:** 3 requests per minute per IP +- **Window:** 1 minute +- **Applied to:** `/export/pdf` endpoint only + +### Implementation + +```go +// Create rate limiter for PDF endpoint +pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute) + +// Apply to PDF endpoint +protectedPDFHandler := middleware.OriginChecker( + pdfRateLimiter.Middleware( + http.HandlerFunc(cvHandler.ExportPDF), + ), +) +``` + +### Behavior + +| Requests | Status | Response | +|----------|--------|----------| +| 1st request | ✅ 200 OK | PDF generated | +| 2nd request | ✅ 200 OK | PDF generated | +| 3rd request | ✅ 200 OK | PDF generated | +| 4th request (within 1 min) | ❌ 429 Too Many Requests | Rate limit exceeded | +| After 1 minute | ✅ 200 OK | Counter reset | + +### Headers + +**Rate Limit Exceeded Response:** +``` +HTTP/1.1 429 Too Many Requests +Retry-After: 60 +Content-Type: text/plain; charset=utf-8 + +Rate limit exceeded. Please try again later. +``` + +### IP Detection + +The rate limiter detects client IP from: +1. `X-Forwarded-For` header (proxy/CDN) +2. `X-Real-IP` header (alternative proxy header) +3. `RemoteAddr` (direct connection) + +**Supports reverse proxies:** Yes (Nginx, Cloudflare, etc.) + +--- + +## 3. Combined Protection + +The PDF endpoint has **both** origin checking and rate limiting applied: + +``` +Request → OriginChecker → RateLimiter → PDF Handler +``` + +**Protection Flow:** + +1. **Check Origin/Referer** + - If external domain → 403 Forbidden + - If production + no headers → 403 Forbidden + - Otherwise, continue + +2. **Check Rate Limit** + - If > 3 requests/minute → 429 Too Many Requests + - Otherwise, continue + +3. **Generate PDF** + - Process request normally + +--- + +## 4. Configuration Examples + +### Development Environment + +```bash +# .env +PORT=1999 +HOST=localhost +GO_ENV=development +ALLOWED_ORIGINS= +``` + +**Behavior:** +- Allows `localhost` and `127.0.0.1` +- Allows requests without headers +- Rate limit: 3 PDF/min per IP + +### Production Environment + +```bash +# .env +PORT=1999 +HOST=0.0.0.0 +GO_ENV=production +ALLOWED_ORIGINS=yourdomain.com,www.yourdomain.com +``` + +**Behavior:** +- Only allows `yourdomain.com` and `www.yourdomain.com` +- Requires `Origin` or `Referer` header for PDF endpoint +- Rate limit: 3 PDF/min per IP + +### Multiple Domains + +```bash +# Support multiple domains (e.g., staging + production) +ALLOWED_ORIGINS=yourdomain.com,www.yourdomain.com,staging.yourdomain.com +``` + +--- + +## 5. Testing Protection + +### Test Origin Checking + +```bash +# ✅ Allowed (localhost in development) +curl http://localhost:1999/export/pdf?lang=en + +# ✅ Allowed (with referer) +curl -H "Referer: http://localhost:1999/" http://localhost:1999/export/pdf?lang=en + +# ❌ Blocked (external referer) +curl -H "Referer: https://evil.com/" http://localhost:1999/export/pdf?lang=en +# Expected: 403 Forbidden +``` + +### Test Rate Limiting + +```bash +# Generate 4 PDFs quickly +for i in {1..4}; do + echo "Request $i:" + curl -w "Status: %{http_code}\n" -o /dev/null -s http://localhost:1999/export/pdf?lang=en + sleep 1 +done + +# Expected output: +# Request 1: Status: 200 +# Request 2: Status: 200 +# Request 3: Status: 200 +# Request 4: Status: 429 +``` + +### Test Combined Protection + +```bash +# Should be blocked by origin checker before rate limiter +for i in {1..5}; do + curl -H "Referer: https://evil.com/" -w "Status: %{http_code}\n" -o /dev/null -s \ + http://localhost:1999/export/pdf?lang=en +done + +# Expected: All requests get 403 (origin check fails immediately) +``` + +--- + +## 6. Monitoring & Logs + +### Log Messages + +**Origin Check Failure:** +``` +# No specific log (returns 403 silently) +# Check server logs for 403 responses +``` + +**Rate Limit Exceeded:** +``` +# No specific log (returns 429 silently) +# Monitor for frequent 429 responses +``` + +### Recommended Monitoring + +1. **Track 403 responses** - Indicates potential attack attempts +2. **Track 429 responses** - Indicates rate limiting in effect +3. **Monitor PDF generation times** - Detect abuse patterns +4. **Alert on sustained high request rates** - DDoS detection + +--- + +## 7. Customization + +### Adjust Rate Limits + +**File:** `main.go` + +```go +// Current: 3 requests per minute +pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute) + +// More restrictive: 5 per hour +pdfRateLimiter := middleware.NewRateLimiter(5, 1*time.Hour) + +// Less restrictive: 10 per minute +pdfRateLimiter := middleware.NewRateLimiter(10, 1*time.Minute) +``` + +### Apply to Other Endpoints + +```go +// Protect /cv endpoint +protectedCVHandler := middleware.OriginChecker( + http.HandlerFunc(cvHandler.CVContent), +) +mux.Handle("/cv", protectedCVHandler) +``` + +### Disable Origin Checking (Not Recommended) + +```go +// Apply only rate limiting (no origin check) +mux.Handle("/export/pdf", pdfRateLimiter.Middleware( + http.HandlerFunc(cvHandler.ExportPDF), +)) +``` + +--- + +## 8. Security Best Practices + +### ✅ Recommended + +1. **Set ALLOWED_ORIGINS in production** - Never run production without it +2. **Use HTTPS** - Prevents header spoofing +3. **Monitor 403/429 responses** - Detect attack patterns +4. **Consider CloudFlare** - Additional DDoS protection layer +5. **Log suspicious requests** - For forensic analysis + +### ❌ Anti-Patterns + +1. **Don't disable protection in production** - Always use origin checking +2. **Don't set rate limits too high** - PDF generation is expensive +3. **Don't trust IP addresses alone** - Use combined protection +4. **Don't expose internal endpoints** - Keep admin routes private + +--- + +## 9. Production Deployment Checklist + +Before deploying to production: + +- [ ] Set `GO_ENV=production` in environment +- [ ] Configure `ALLOWED_ORIGINS` with your domain(s) +- [ ] Test origin checking with external domain +- [ ] Test rate limiting with rapid requests +- [ ] Verify HTTPS is enabled (prevents header spoofing) +- [ ] Set up monitoring for 403/429 responses +- [ ] Configure log retention for security analysis +- [ ] Test PDF generation under load +- [ ] Verify reverse proxy headers (X-Forwarded-For) +- [ ] Document allowed origins in runbook + +--- + +## 10. Troubleshooting + +### Problem: Legitimate users getting 403 + +**Cause:** ALLOWED_ORIGINS not configured correctly + +**Solution:** +```bash +# Ensure all your domains are listed +ALLOWED_ORIGINS=yourdomain.com,www.yourdomain.com + +# Check for typos (case-insensitive but must match exactly) +``` + +### Problem: Rate limit too restrictive + +**Cause:** Legitimate users hitting limit + +**Solution:** +```go +// Increase limit or window in main.go +pdfRateLimiter := middleware.NewRateLimiter(5, 1*time.Minute) +``` + +### Problem: Behind reverse proxy, rate limit not working + +**Cause:** IP detection failing + +**Solution:** +```nginx +# Ensure Nginx passes correct headers +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Real-IP $remote_addr; +``` + +### Problem: Origin header not being sent + +**Cause:** Browser doesn't send Origin for same-origin requests + +**Solution:** This is normal. The middleware checks Referer as fallback. + +--- + +## 11. Attack Scenarios & Mitigation + +### Scenario 1: DDoS via PDF Generation + +**Attack:** External site hotlinks to `/export/pdf`, triggering many PDF generations + +**Mitigation:** +1. ✅ Origin checker blocks external domains (403) +2. ✅ Rate limiter prevents >3 requests/min per IP (429) +3. ✅ Production mode requires headers (blocks direct access) + +**Result:** Attack fails, server protected + +### Scenario 2: Header Spoofing + +**Attack:** Attacker spoofs `Referer` header to bypass origin check + +**Mitigation:** +1. ⚠️ HTTPS prevents header modification in transit +2. ✅ Rate limiter still applies (3 req/min limit) +3. ✅ IP-based tracking prevents distributed spoofing + +**Result:** Individual attacker limited to 3 req/min + +### Scenario 3: Distributed Attack + +**Attack:** Botnet with many IPs, each generating PDFs + +**Mitigation:** +1. ✅ Each IP limited to 3 req/min +2. ✅ Origin checker blocks if no valid referer +3. 🔴 Consider CloudFlare for large-scale DDoS + +**Result:** Slowed but not fully blocked (add CloudFlare) + +--- + +## Summary + +**Protection Enabled:** ✅ Origin Checking + Rate Limiting + +**Endpoints Protected:** +- `/export/pdf` - Full protection (origin + rate limit) + +**Endpoints Unprotected:** +- `/` - Public home page +- `/cv` - Public CV content +- `/health` - Public health check +- `/static/*` - Public static files + +**Configuration:** Environment-based via `ALLOWED_ORIGINS` + +**Production Ready:** Yes (after setting ALLOWED_ORIGINS) + +--- + +**For questions or to adjust protection levels, modify:** +- `internal/middleware/security.go` - Origin checking and rate limiting logic +- `main.go` - Apply protection to additional endpoints +- `.env` - Configure ALLOWED_ORIGINS for your domain diff --git a/internal/middleware/security.go b/internal/middleware/security.go index c5b20f7..3ca4e7b 100644 --- a/internal/middleware/security.go +++ b/internal/middleware/security.go @@ -3,6 +3,9 @@ package middleware import ( "net/http" "os" + "strings" + "sync" + "time" ) // SecurityHeaders adds production-grade security headers to responses @@ -47,3 +50,174 @@ func SecurityHeaders(next http.Handler) http.Handler { 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("Origin") + 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("Referer") + 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("GO_ENV") == "production" && r.URL.Path == "/export/pdf" { + // 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("X-Forwarded-For") + if ip == "" { + ip = r.Header.Get("X-Real-IP") + } + if ip == "" { + ip = strings.Split(r.RemoteAddr, ":")[0] + } + + if !rl.allow(ip) { + w.Header().Set("Retry-After", "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(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() + } +} diff --git a/main.go b/main.go index 11f5f27..98c2497 100644 --- a/main.go +++ b/main.go @@ -40,12 +40,23 @@ func main() { // Setup router mux := http.NewServeMux() + // Create rate limiter for PDF endpoint + // Allow 3 PDF generations per minute per IP + pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute) + // Routes mux.HandleFunc("/", cvHandler.Home) mux.HandleFunc("/cv", cvHandler.CVContent) - mux.HandleFunc("/export/pdf", cvHandler.ExportPDF) mux.HandleFunc("/health", healthHandler.Check) + // Protected PDF endpoint with origin checking + rate limiting + protectedPDFHandler := middleware.OriginChecker( + pdfRateLimiter.Middleware( + http.HandlerFunc(cvHandler.ExportPDF), + ), + ) + mux.Handle("/export/pdf", protectedPDFHandler) + // Static files with cache control staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static"))) mux.Handle("/static/", cacheControl(staticHandler))