c89b67a06d
- 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)
118 lines
3.0 KiB
Go
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
|
|
}
|