Files
cv-site/internal/middleware/browser_only.go
T
juanatsap f91a24ea9b feat: Add plain text CV endpoint and contact form with security
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
2025-11-30 13:47:49 +00:00

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
}