Defensive Security: CSRF, CSP, Rate Limiting & CORS
Building a production email server requires more than just functionality—it demands defensive security at every layer. In the email-server project, I implemented comprehensive security measures across multiple commits to protect against common vulnerabilities.
This article covers the defensive security implementations, including CSRF protection, Content Security Policy hardening, rate limiting strategies, and CORS blocking—all essential for securing web applications in production.
CSRF Protection: Defending Against Cross-Site Request Forgery
Cross-Site Request Forgery (CSRF) attacks trick authenticated users into executing unwanted actions on a web application. The email-server project encountered and resolved several CSRF-related issues through iterative improvements.
The Problem: Wizard CSRF Failures
The initial implementation followed a common pattern: generate a token, validate it on POST requests, and immediately invalidate it. However, this caused issues with multi-step wizards that required multiple AJAX requests.
Commit b175bc5 revealed the issue when the DNS wizard for domain configuration began failing. The wizard required multiple AJAX calls:
// @filename: index.js
// validateDomain() - First AJAX call
fetch('/admin/domains/wizard/validate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({domain: domain})
})
// fetchDNSRecords() - Second AJAX call
fetch('/admin/domains/wizard/dns-records', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({...})
})
After the first call succeeded, the CSRF token was invalidated, causing subsequent calls to fail with “Invalid or expired CSRF token” errors.
Initial Approach: Single-Step Form (Commit f5156ca)
The first attempt simplified the domain form to a single step, removing the need for multiple AJAX requests:
<!-- Simple form POST instead of AJAX -->
<form method="POST" action="/admin/domains/add">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
<div class="form-group">
<label for="name">Domain Name</label>
<input
type="text"
id="name"
name="name"
class="form-control"
required
placeholder="example.com"
value="{{.Domain}}"
/>
</div>
</form>
While this solved the CSRF issue, it sacrificed user experience by eliminating the helpful multi-step wizard that guided users through DNS configuration.
Final Solution: Token Reuse Until Expiration (Commit a8755f4)
The optimal approach allows CSRF tokens to be reused until they naturally expire (1 hour). This supports multi-step wizards and AJAX calls while maintaining security:
// @filename: main.go
// withCSRF wraps a handler with CSRF protection
func (s *Server) withCSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip CSRF for GET/HEAD/OPTIONS
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
token := generateToken()
csrfTokensMu.Lock()
csrfTokens[token] = time.Now().Add(1 * time.Hour)
csrfTokensMu.Unlock()
w.Header().Set("X-CSRF-Token", token)
next.ServeHTTP(w, r)
return
}
// Validate CSRF token for state-changing requests
token := r.FormValue("csrf_token")
if token == "" {
token = r.Header.Get("X-CSRF-Token")
}
if !isValidToken(token) {
http.Error(w, "Invalid or expired CSRF token", http.StatusForbidden)
return
}
csrfTokensMu.RLock()
expiry, exists := csrfTokens[token]
csrfTokensMu.RUnlock()
now := time.Now()
if !exists || now.After(expiry) {
http.Error(w, "Invalid or expired CSRF token", http.StatusForbidden)
return
}
// Keep token valid for reuse (for multi-step wizards and AJAX calls)
// Token will naturally expire after 1 hour
w.Header().Set("X-CSRF-Token", token)
next.ServeHTTP(w, r)
})
}
AJAX Request Token Handling
The wizard JavaScript now includes the CSRF token in all AJAX requests:
// @filename: index.js
var wizardData = {
domain: '',
selector: 'mail',
bits: 2048,
storage: 'database',
stateId: '',
records: null,
skipped: false,
csrfToken: '{{.CSRFToken}}' // Embedded from server
};
function validateDomain() {
fetch('/admin/domains/wizard/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': wizardData.csrfToken // Include in header
},
body: JSON.stringify({domain: domain})
})
.then(function(r) { return r.json(); })
.then(function(data) {
// Handle response
});
}
function fetchDNSRecords() {
fetch('/admin/domains/wizard/dns-records', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': wizardData.csrfToken // Same token reused
},
body: JSON.stringify({...})
})
}
Token Generation: Cryptographic Security
CSRF tokens must be cryptographically secure to prevent prediction attacks:
// @filename: handlers.go
func generateToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return hex.EncodeToString(b)
}
func isValidToken(token string) bool {
// Minimum length check (32 hex chars = 16 bytes)
if len(token) < 32 {
return false
}
// Maximum length check to prevent DoS
if len(token) > 128 {
return false
}
// Validate hex encoding
_, err := hex.DecodeString(token)
return err == nil
}
Key Security Points:
- Uses
crypto/rand(notmath/rand) for cryptographically secure randomness - Panics on RNG failure rather than falling back to weak tokens
- Validates token format and length to prevent DoS attacks
- Tokens expire after 1 hour automatically
Periodic Cleanup
CSRF tokens are cleaned up periodically to prevent memory leaks:
// @filename: handlers.go
func CleanupExpiredSessions(db *sql.DB) {
ticker := time.NewTicker(15 * time.Minute)
go func() {
for range ticker.C {
now := time.Now()
// Clean CSRF tokens
csrfTokensMu.Lock()
for token, expiry := range csrfTokens {
if now.After(expiry) {
delete(csrfTokens, token)
}
}
csrfTokensMu.Unlock()
}
}()
}
Content Security Policy (CSP) Hardening
Content Security Policy is a powerful defense against XSS attacks, injection of malicious scripts, and other code injection vulnerabilities. Commit 47399d7 significantly hardened the CSP implementation.
CSP Implementation
// @filename: main.go
func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Content Security Policy - restrict resource loading
// Note: 'unsafe-inline' needed for admin panel inline scripts
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline'; "+ // Allow inline scripts for admin UI
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"font-src 'self'; "+
"connect-src 'self'; "+
"form-action 'self'; "+
"frame-ancestors 'none'; "+
"base-uri 'self'; "+
"object-src 'none'; "+ // Block plugins
"upgrade-insecure-requests")
next.ServeHTTP(w, r)
})
}
Key CSP Directives
1. object-src 'none'
This directive blocks loading of plugins (Flash, Java, PDFs, etc.) which have a history of security vulnerabilities:
object-src 'none'
Why: Plugin vulnerabilities have been exploited for years. Blocking them eliminates an entire attack surface.
2. FLoC Blocking (Interest Cohort API)
FLoC (Federated Learning of Cohorts) was Google’s controversial attempt to track users across sites without cookies. The Permissions Policy header blocks it:
// @filename: main.go
w.Header().Set("Permissions-Policy",
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), "+
"magnetometer=(), microphone=(), payment=(), usb=(), "+
"interest-cohort=()") // Block FLoC tracking
Why: FLoC raised privacy concerns by creating behavioral profiles without user consent. Blocking it prevents browser-based tracking.
3. Inline Script Handling
The admin panel uses inline scripts for interactivity, requiring unsafe-inline:
script-src 'self' 'unsafe-inline'
Better approach for production: Extract all inline scripts into separate files and use nonces or hashes:
script-src 'self' 'nonce-{random}' 'sha256-{hash}'
4. Frame Protection
frame-ancestors 'none'
X-Frame-Options: DENY
Prevents the admin panel from being embedded in iframes, protecting against clickjacking attacks.
5. Form Action Restriction
form-action 'self'
Prevents forms from submitting data to external domains, mitigating CSRF attempts that bypass token validation.
6. Base URI Restriction
base-uri 'self'
Prevents malicious scripts from changing the base URL, which could be used to load resources from external domains.
Other Essential Security Headers
// @filename: main.go
// Prevent clickjacking
w.Header().Set("X-Frame-Options", "DENY")
// Prevent MIME type sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")
// XSS protection (legacy, but still useful for older browsers)
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Referrer policy - don't leak URLs to external sites
w.Header().Set("Referrer-Policy", "no-referrer")
Rate Limiting Implementation
Rate limiting protects against brute force attacks, credential stuffing, and DoS attacks. The email-server implements multiple rate limiting strategies.
UserRateLimiter: Per-User Limits
Limits email sending per user to prevent abuse:
// @filename: handlers.go
type UserRateLimiter struct {
mu sync.RWMutex
counters map[int64]*userSendCounter
maxPerHour int
maxPerDay int
stopCleanup chan struct{}
}
type userSendCounter struct {
hourCount int
dayCount int
hourReset time.Time
dayReset time.Time
lastAccess time.Time // Track last access for cleanup
}
func NewUserRateLimiter(maxPerHour, maxPerDay int) *UserRateLimiter {
rl := &UserRateLimiter{
counters: make(map[int64]*userSendCounter),
maxPerHour: maxPerHour,
maxPerDay: maxPerDay,
stopCleanup: make(chan struct{}),
}
// Start cleanup goroutine to prevent unbounded memory growth
go rl.cleanupLoop()
return rl
}
Memory Leak Fix: Periodic Cleanup (Commit e7b2314)
The initial implementation suffered from unbounded map growth. UserRateLimiter accumulated entries indefinitely, leading to memory leaks in long-running services.
Before:
// @filename: main.go
// No cleanup - map grows unbounded
func (rl *UserRateLimiter) CheckAndIncrement(userID int64) error {
rl.mu.Lock()
defer rl.mu.Unlock()
// ... check and increment logic
// Never removes old entries!
}
After:
// @filename: main.go
// cleanupLoop periodically removes stale entries
func (rl *UserRateLimiter) cleanupLoop() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
rl.cleanup()
case <-rl.stopCleanup:
return
}
}
}
// cleanup removes entries that haven't been accessed in over 48 hours
func (rl *UserRateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := time.Now().Add(-48 * time.Hour)
for userID, counter := range rl.counters {
if counter.lastAccess.Before(cutoff) {
delete(rl.counters, userID)
}
}
}
APIRateLimiter: Per-API-Key Limits
// @filename: main.go
// RateLimiter tracks failed login attempts per IP
type RateLimiter struct {
mu sync.RWMutex
attempts map[string]*attemptInfo
// Configuration
maxAttempts int
windowSize time.Duration
blockDuration time.Duration
trustedProxies map[string]bool // Only trust proxy headers from these IPs
}
type attemptInfo struct {
count int
firstTime time.Time
blockedAt time.Time
}
Trusted Proxy Configuration
For accurate rate limiting behind reverse proxies (nginx), we must trust proxy headers only from trusted sources:
// @filename: handlers.go
// DefaultRateLimiter returns a rate limiter with sensible defaults
// 5 attempts per 15 minutes, 30 minute block
// Trusts localhost as proxy (for nginx reverse proxy)
func DefaultRateLimiter() *RateLimiter {
// Trust nginx on localhost - this allows rate limiting by actual client IP
return NewRateLimiter(5, 15*time.Minute, 30*time.Minute, []string{"127.0.0.1", "::1"})
}
// GetClientIP extracts the client IP from the request
// Only trusts X-Forwarded-For/X-Real-IP if request comes from a trusted proxy
func (rl *RateLimiter) GetClientIP(r *http.Request) string {
// First get the direct connection IP
directIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
directIP = r.RemoteAddr
}
// Only trust proxy headers if the direct connection is from a trusted proxy
if len(rl.trustedProxies) > 0 && rl.trustedProxies[directIP] {
// Check X-Forwarded-For header (for reverse proxy)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the first IP in the chain (original client)
ips := strings.Split(xff, ",")
if len(ips) > 0 {
clientIP := strings.TrimSpace(ips[0])
if net.ParseIP(clientIP) != nil {
return clientIP
}
}
}
// Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" {
if net.ParseIP(xri) != nil {
return xri
}
}
}
// Use direct connection IP (don't trust headers from untrusted sources)
return directIP
}
Rate Limiting in Practice
// @filename: main.go
// handleLogin handles admin login with rate limiting
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
clientIP := s.rateLimiter.GetClientIP(r)
// Check if IP is blocked
if s.rateLimiter.IsBlocked(clientIP) {
until := s.rateLimiter.BlockedUntil(clientIP)
w.Header().Set("Retry-After", strconv.Itoa(int(time.Until(until).Seconds())))
http.Error(w, "Too many login attempts. Please try again later.", http.StatusTooManyRequests)
return
}
// Check credentials
var loginData struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&loginData); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Authenticate
userID, err := s.authenticateUser(loginData.Email, loginData.Password)
if err != nil {
// Record failure
if s.rateLimiter.RecordFailure(clientIP) {
// IP is now blocked
w.Header().Set("Retry-After", strconv.Itoa(int(30*60)))
http.Error(w, "Too many failed login attempts. Blocked for 30 minutes.", http.StatusTooManyRequests)
} else {
remaining := s.rateLimiter.RemainingAttempts(clientIP)
http.Error(w, fmt.Sprintf("Invalid credentials. %d attempts remaining.", remaining), http.StatusUnauthorized)
}
return
}
// Success - clear rate limit
s.rateLimiter.RecordSuccess(clientIP)
// Create session
token := s.createSession(userID)
// Set cookie
http.SetCookie(w, &http.Cookie{
Name: "admin_session",
Value: token,
Path: "/admin",
HttpOnly: true,
Secure: isSecureContext(r),
SameSite: http.SameSiteStrictMode,
MaxAge: 86400, // 24 hours
})
}
CORS Blocking: Explicit Deny for External Requests
Cross-Origin Resource Sharing (CORS) can be a significant security risk if not properly configured. The email-server takes a defensive approach: explicitly deny all cross-origin requests.
// @filename: main.go
func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CRITICAL: Block all cross-origin requests - admin panel should NEVER be accessed from external sites
w.Header().Set("Access-Control-Allow-Origin", "") // Explicitly NO CORS
w.Header().Set("Access-Control-Allow-Methods", "")
w.Header().Set("Access-Control-Allow-Headers", "")
next.ServeHTTP(w, r)
})
}
Why Block CORS Entirely?
For an admin panel:
- No legitimate cross-origin use case: The admin panel should only be accessed directly
- Prevents CSRF via CORS: Attackers cannot use CORS to bypass SameSite cookie protections
- Stops information leaks: External sites cannot make authenticated requests to the admin panel
When to Allow CORS
CORS should only be enabled for public APIs that are designed to be accessed from external origins:
// @filename: main.go
// Example: For a public API endpoint
if strings.HasPrefix(r.URL.Path, "/api/public/") {
w.Header().Set("Access-Control-Allow-Origin", "https://trusted-domain.com")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
Referrer Policy: Maximum Privacy
The Referrer-Policy header controls what information is sent in the Referer header when users navigate from your page to another.
w.Header().Set("Referrer-Policy", "no-referrer")
Policy Options
| Policy | Description | Privacy Level |
|---|---|---|
no-referrer | Never send referrer | Maximum |
no-referrer-when-downgrade | Default behavior | Low |
same-origin | Only send for same-origin | High |
strict-origin-when-cross-origin | Send origin only | Medium |
origin | Send only origin | High |
Why no-referrer?
For an email admin panel handling sensitive data:
- Prevents information leakage: Sensitive URLs (with query parameters) won’t be sent to external sites
- Maximum privacy: No referrer information is ever transmitted
- No legitimate use case: Users shouldn’t be navigating from admin panel to external sites
Use Cases for Different Policies
// @filename: main.go
// Admin panel: Maximum privacy
w.Header().Set("Referrer-Policy", "no-referrer")
// Public content: Standard security
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Analytics/tracking: Allow same-origin
w.Header().Set("Referrer-Policy", "same-origin")
Secure Cookie Handling
Cookies must be configured securely to prevent theft and session hijacking.
Secure Context Detection
The Secure flag should only be set when the request is truly over HTTPS:
// @filename: handlers.go
func isSecureContext(r *http.Request) bool {
// Direct TLS connection
if r.TLS != nil {
return true
}
// Check if behind HTTPS proxy
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" {
return true
}
// In production with nginx, always return true
// This is safer than potentially setting Secure=false
return true
}
Secure Cookie Configuration
// @filename: main.go
http.SetCookie(w, &http.Cookie{
Name: "admin_session",
Value: token,
Path: "/admin",
HttpOnly: true, // Prevent JavaScript access
Secure: isSecureContext(r), // Only send over HTTPS
SameSite: http.SameSiteStrictMode, // CSRF protection
MaxAge: 86400, // 24 hour expiration
})
Key Cookie Security Attributes:
- HttpOnly: Prevents JavaScript from accessing the cookie (XSS protection)
- Secure: Only sends cookie over HTTPS
- SameSite=Strict: Prevents CSRF attacks by not sending cookies with cross-site requests
- Path: Restricts cookie to specific paths
- MaxAge: Sets expiration time (no persistent cookies)
Security Headers Summary
| Header | Purpose | Value |
|---|---|---|
Content-Security-Policy | Restricts resource loading | default-src 'self' |
X-Frame-Options | Prevents clickjacking | DENY |
X-Content-Type-Options | Prevents MIME sniffing | nosniff |
X-XSS-Protection | XSS protection (legacy) | 1; mode=block |
Referrer-Policy | Controls referrer header | no-referrer |
Permissions-Policy | Controls browser features | camera=(), geolocation=(), ... |
Access-Control-Allow-Origin | CORS control | Empty (block all) |
Testing Security Controls
Testing CSRF Protection
# @filename: script.sh
# Test CSRF token validation
curl -X POST http://localhost:8080/admin/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"wrong"}' \
-v 2>&1 | grep "403"
# Should return 403 Forbidden without token
# Test with invalid token
curl -X POST http://localhost:8080/admin/login \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: invalid-token-123" \
-d '{"email":"admin@example.com","password":"wrong"}' \
-v 2>&1 | grep "403"
Testing CSP
# @filename: script.sh
# Check CSP header
curl -I http://localhost:8080/admin/ 2>&1 | grep -i "content-security-policy"
# Expected output:
# Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; ...
# Test inline script blocking (if configured)
# Create a test page with inline script and try to inject
Testing Rate Limiting
# @filename: script.sh
# Test rate limiting
for i in {1..10}; do
curl -X POST http://localhost:8080/admin/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"wrong"}' \
-w "\nStatus: %{http_code}\n" \
-o /dev/null
done
# Should see 429 Too Many Requests after 5 attempts
Testing CORS Blocking
# @filename: script.sh
# Test CORS headers
curl -I http://localhost:8080/admin/ -H "Origin: https://evil.com" 2>&1 | grep -i "access-control"
# Should show empty Access-Control-Allow-Origin header
# Test preflight request
curl -X OPTIONS http://localhost:8080/admin/ \
-H "Origin: https://evil.com" \
-H "Access-Control-Request-Method: POST" \
-v 2>&1 | grep -i "access-control"
Testing Security Headers
# @filename: script.sh
# Check all security headers
curl -I http://localhost:8080/admin/ 2>&1 | grep -E "(X-Frame|X-Content|Referrer|Permissions|Content-Security)"
# Expected output:
# X-Frame-Options: DENY
# X-Content-Type-Options: nosniff
# Referrer-Policy: no-referrer
# Permissions-Policy: accelerometer=(), camera=(), ...
# Content-Security-Policy: default-src 'self'; ...
Automated Testing with OWASP ZAP
# @filename: script.sh
# Run OWASP ZAP security scan
docker run -t owasp/zap2docker-stable zap-baseline.py \
-t http://localhost:8080/admin/ \
-r report.html
# Review the report for:
# - Missing security headers
# - CSRF vulnerabilities
# - CSP violations
# - Information disclosure
Security Best Practices Summary
CSRF Protection
- Use cryptographically secure random tokens
- Allow token reuse until expiration for multi-step workflows
- Validate tokens on all state-changing requests
- Include tokens in AJAX requests via headers
- Implement periodic token cleanup
CSP Implementation
- Use
object-src 'none'to block plugins - Block FLoC with
interest-cohort=()in Permissions Policy - Extract inline scripts to separate files when possible
- Use nonces or hashes instead of
unsafe-inlinein production - Include
frame-ancestors 'none'to prevent clickjacking
Rate Limiting
- Implement per-IP and per-user limits
- Use trusted proxy lists for accurate client IP detection
- Implement periodic cleanup to prevent memory leaks
- Block IPs after exceeding limits
- Provide clear error messages with retry information
CORS Configuration
- Default to denying all cross-origin requests
- Only enable CORS for explicitly allowed origins
- Restrict allowed methods and headers
- Never use
*in production
Referrer Policy
- Use
no-referrerfor admin panels and sensitive areas - Use
strict-origin-when-cross-originfor public content - Never send sensitive data in referrer URLs
Cookie Security
- Always set
HttpOnlyflag - Set
Secureflag only on HTTPS - Use
SameSite=Strictfor maximum CSRF protection - Set appropriate expiration times
- Restrict cookies to specific paths
Conclusion
Defensive security requires multiple layers of protection. The email-server project demonstrates how CSRF protection, CSP hardening, rate limiting, CORS blocking, and security headers work together to create a secure application.
The key lessons learned:
- Security is iterative: Issues like CSRF token handling emerge through real-world usage
- Defense in depth: Multiple security measures compensate for potential weaknesses
- Memory management matters: Even security features can introduce vulnerabilities (memory leaks)
- Proxy-aware design: Rate limiting must work correctly behind reverse proxies
- Default deny: Explicitly deny rather than implicitly allow for maximum security
By implementing these defensive security measures, you can significantly reduce the attack surface of your web applications and protect against common vulnerabilities.
