Files
cv-site/internal/middleware/browser_only.go
T
juanatsap c89b67a06d refactor: consolidate lang into constants, rename services to email
- Merge lang package into constants (add IsValidLang, ValidateLang, AllLangs)
- Rename internal/services to internal/email for consistency with pdf package
- Rename types to avoid redundancy: EmailService→Service, EmailConfig→Config
- Update all imports and references across codebase
- Delete internal/lang directory (functions moved to constants)
2025-12-06 17:05:17 +00:00

118 lines
3.0 KiB
Go

package middleware
import (
"log"
"net/http"
"strings"
c "github.com/juanatsap/cv-site/internal/constants"
)
// 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(c.HeaderUserAgent)
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(c.HeaderReferer)
origin := r.Header.Get(c.HeaderOrigin)
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(c.HeaderHXRequest) == "true"
hasXMLHTTPHeader := r.Header.Get(c.HeaderXRequestedWith) == c.HeaderValueXMLHTTPRequest
hasCustomBrowserHeader := r.Header.Get(c.HeaderXBrowserReq) == "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(c.HeaderXForwardedFor)
if ip != "" {
// Take first IP if multiple
ips := strings.Split(ip, ",")
return strings.TrimSpace(ips[0])
}
// Try X-Real-IP
ip = r.Header.Get(c.HeaderXRealIP)
if ip != "" {
return ip
}
// Fallback to RemoteAddr
ip = r.RemoteAddr
// Remove port
if idx := strings.LastIndex(ip, ":"); idx != -1 {
ip = ip[:idx]
}
return ip
}