121 lines
3.1 KiB
Go
121 lines
3.1 KiB
Go
|
|
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
|
||
|
|
}
|