Files
cv-site/internal/middleware/csrf.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

201 lines
4.6 KiB
Go

package middleware
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"net/http"
"sync"
"time"
)
const (
csrfTokenLength = 32
csrfCookieName = "csrf_token"
csrfFormField = "csrf_token"
csrfTokenTTL = 24 * time.Hour
)
// csrfTokenEntry stores token with expiration
type csrfTokenEntry struct {
token string
expiresAt time.Time
}
// CSRFProtection provides CSRF token generation and validation
type CSRFProtection struct {
mu sync.RWMutex
tokens map[string]*csrfTokenEntry // map[token]entry
}
// NewCSRFProtection creates a new CSRF protection instance
func NewCSRFProtection() *CSRFProtection {
csrf := &CSRFProtection{
tokens: make(map[string]*csrfTokenEntry),
}
// Cleanup expired tokens every hour
go csrf.cleanup()
return csrf
}
// Middleware provides CSRF protection for state-changing operations
// GET requests: Generate and set CSRF token
// POST/PUT/DELETE: Validate CSRF token
func (c *CSRFProtection) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only validate on state-changing methods
if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete {
if !c.validateToken(r) {
log.Printf("SECURITY: CSRF validation failed from IP %s", getClientIP(r))
// Check if HTMX request
isHTMX := r.Header.Get("HX-Request") != ""
if isHTMX {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`<div class="alert alert-error">
<h3>Security Error</h3>
<p>Invalid security token. Please refresh the page and try again.</p>
</div>`))
} else {
http.Error(w, "CSRF validation failed", http.StatusForbidden)
}
return
}
}
next.ServeHTTP(w, r)
})
}
// generateToken creates a new CSRF token
func (c *CSRFProtection) generateToken() (string, error) {
bytes := make([]byte, csrfTokenLength)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
token := base64.URLEncoding.EncodeToString(bytes)
// Store token with expiration
c.mu.Lock()
c.tokens[token] = &csrfTokenEntry{
token: token,
expiresAt: time.Now().Add(csrfTokenTTL),
}
c.mu.Unlock()
return token, nil
}
// GetToken retrieves or generates a CSRF token for the request
// This should be called when rendering forms
func (c *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (string, error) {
// Check if token exists in cookie
cookie, err := r.Cookie(csrfCookieName)
if err == nil && cookie.Value != "" {
// Validate existing token
c.mu.RLock()
entry, exists := c.tokens[cookie.Value]
c.mu.RUnlock()
if exists && time.Now().Before(entry.expiresAt) {
// Token is valid, return it
return cookie.Value, nil
}
}
// Generate new token
token, err := c.generateToken()
if err != nil {
return "", fmt.Errorf("failed to generate CSRF token: %w", err)
}
// Set cookie
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil, // Only set Secure flag if using HTTPS
SameSite: http.SameSiteStrictMode,
MaxAge: int(csrfTokenTTL.Seconds()),
})
return token, nil
}
// validateToken validates the CSRF token from the request
func (c *CSRFProtection) validateToken(r *http.Request) bool {
// Get token from form
var formToken string
// Try form value first
if err := r.ParseForm(); err == nil {
formToken = r.FormValue(csrfFormField)
}
// If not in form, try header (for AJAX requests)
if formToken == "" {
formToken = r.Header.Get("X-CSRF-Token")
}
if formToken == "" {
log.Printf("CSRF: No token in request")
return false
}
// Get token from cookie
cookie, err := r.Cookie(csrfCookieName)
if err != nil || cookie.Value == "" {
log.Printf("CSRF: No token in cookie")
return false
}
// Tokens must match
if formToken != cookie.Value {
log.Printf("CSRF: Token mismatch")
return false
}
// Validate token exists and is not expired
c.mu.RLock()
entry, exists := c.tokens[formToken]
c.mu.RUnlock()
if !exists {
log.Printf("CSRF: Token not found in store")
return false
}
if time.Now().After(entry.expiresAt) {
log.Printf("CSRF: Token expired")
return false
}
return true
}
// cleanup removes expired tokens periodically
func (c *CSRFProtection) cleanup() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for token, entry := range c.tokens {
if now.After(entry.expiresAt) {
delete(c.tokens, token)
}
}
c.mu.Unlock()
}
}
// Note: getClientIP is defined in security_logger.go