f91a24ea9b
Plain text endpoint: - Add /text route for plain text CV (for curl/AI crawlers) - Use k3a/html2text library for HTML-to-text conversion - Add Plain Text button to hamburger menu with UI translations Contact form feature: - Add ContactHandler with proper email service integration - Add CSRF protection middleware - Add rate limiting (5 submissions/hour per IP) - Add honeypot and timing-based bot protection - Add input validation with detailed error messages - Add security logging middleware - Add browser-only middleware for API protection Code quality: - Fix all golangci-lint errcheck warnings for w.Write calls - Remove duplicate getClientIP functions - Wire up ContactHandler in routes.Setup
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
|
|
}
|