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 }