monorepo/cloud/maplepress-backend/internal/http/middleware/security_headers.go

251 lines
8.1 KiB
Go

package middleware
import (
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
// SecurityHeadersMiddleware adds security headers to all HTTP responses
// This addresses CWE-693 (Protection Mechanism Failure) and M-2 (Missing Security Headers)
type SecurityHeadersMiddleware struct {
config *config.Config
logger *zap.Logger
}
// NewSecurityHeadersMiddleware creates a new security headers middleware
func NewSecurityHeadersMiddleware(cfg *config.Config, logger *zap.Logger) *SecurityHeadersMiddleware {
return &SecurityHeadersMiddleware{
config: cfg,
logger: logger.Named("security-headers"),
}
}
// Handler wraps an HTTP handler with security headers and CORS
func (m *SecurityHeadersMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Add CORS headers
m.addCORSHeaders(w, r)
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Add security headers before calling next handler
m.addSecurityHeaders(w, r)
// Call the next handler
next.ServeHTTP(w, r)
})
}
// addCORSHeaders adds CORS headers for cross-origin requests
func (m *SecurityHeadersMiddleware) addCORSHeaders(w http.ResponseWriter, r *http.Request) {
// Allow requests from frontend development server and production origins
origin := r.Header.Get("Origin")
// Build allowed origins map
allowedOrigins := make(map[string]bool)
// In development, always allow localhost origins
if m.config.App.Environment == "development" {
allowedOrigins["http://localhost:5173"] = true // Vite dev server
allowedOrigins["http://localhost:5174"] = true // Alternative Vite port
allowedOrigins["http://localhost:3000"] = true // Common React port
allowedOrigins["http://127.0.0.1:5173"] = true
allowedOrigins["http://127.0.0.1:5174"] = true
allowedOrigins["http://127.0.0.1:3000"] = true
}
// Add production origins from configuration
for _, allowedOrigin := range m.config.Security.AllowedOrigins {
if allowedOrigin != "" {
allowedOrigins[allowedOrigin] = true
}
}
// Check if the request origin is allowed
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Tenant-ID")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "3600") // Cache preflight for 1 hour
m.logger.Debug("CORS headers added",
zap.String("origin", origin),
zap.String("path", r.URL.Path))
} else if origin != "" {
// Log rejected origins for debugging
m.logger.Warn("CORS request from disallowed origin",
zap.String("origin", origin),
zap.String("path", r.URL.Path),
zap.Strings("allowed_origins", m.config.Security.AllowedOrigins))
}
}
// addSecurityHeaders adds all security headers to the response
func (m *SecurityHeadersMiddleware) addSecurityHeaders(w http.ResponseWriter, r *http.Request) {
// X-Content-Type-Options: Prevent MIME-sniffing
// Prevents browsers from trying to guess the content type
w.Header().Set("X-Content-Type-Options", "nosniff")
// X-Frame-Options: Prevent clickjacking
// Prevents the page from being embedded in an iframe
w.Header().Set("X-Frame-Options", "DENY")
// X-XSS-Protection: Enable browser XSS protection (legacy browsers)
// Modern browsers use CSP, but this helps with older browsers
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Strict-Transport-Security: Force HTTPS
// Only send this header if request is over HTTPS
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
// max-age=31536000 (1 year), includeSubDomains, preload
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
// Content-Security-Policy: Prevent XSS and injection attacks
// This is a strict policy for an API backend
csp := m.buildContentSecurityPolicy()
w.Header().Set("Content-Security-Policy", csp)
// Referrer-Policy: Control referrer information
// "strict-origin-when-cross-origin" provides a good balance of security and functionality
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Permissions-Policy: Control browser features
// Disable features that an API doesn't need
permissionsPolicy := m.buildPermissionsPolicy()
w.Header().Set("Permissions-Policy", permissionsPolicy)
// X-Permitted-Cross-Domain-Policies: Restrict cross-domain policies
// Prevents Adobe Flash and PDF files from loading data from this domain
w.Header().Set("X-Permitted-Cross-Domain-Policies", "none")
// Cache-Control: Prevent caching of sensitive data
// For API responses, we generally don't want caching
if m.shouldPreventCaching(r) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
// CORS headers (if needed)
// Note: CORS is already handled by a separate middleware if configured
// This just ensures we don't accidentally expose the API to all origins
m.logger.Debug("security headers added",
zap.String("path", r.URL.Path),
zap.String("method", r.Method))
}
// buildContentSecurityPolicy builds the Content-Security-Policy header value
func (m *SecurityHeadersMiddleware) buildContentSecurityPolicy() string {
// For an API backend, we want a very restrictive CSP
// This prevents any content from being loaded except from the API itself
policies := []string{
"default-src 'none'", // Block everything by default
"img-src 'self'", // Allow images only from same origin (for potential future use)
"font-src 'none'", // No fonts needed for API
"style-src 'none'", // No styles needed for API
"script-src 'none'", // No scripts needed for API
"connect-src 'self'", // Allow API calls to self
"frame-ancestors 'none'", // Prevent embedding (same as X-Frame-Options: DENY)
"base-uri 'self'", // Restrict <base> tag
"form-action 'self'", // Restrict form submissions
"upgrade-insecure-requests", // Upgrade HTTP to HTTPS
}
csp := ""
for i, policy := range policies {
if i > 0 {
csp += "; "
}
csp += policy
}
return csp
}
// buildPermissionsPolicy builds the Permissions-Policy header value
func (m *SecurityHeadersMiddleware) buildPermissionsPolicy() string {
// Disable all features that an API doesn't need
// This is the most restrictive policy
features := []string{
"accelerometer=()",
"ambient-light-sensor=()",
"autoplay=()",
"battery=()",
"camera=()",
"cross-origin-isolated=()",
"display-capture=()",
"document-domain=()",
"encrypted-media=()",
"execution-while-not-rendered=()",
"execution-while-out-of-viewport=()",
"fullscreen=()",
"geolocation=()",
"gyroscope=()",
"keyboard-map=()",
"magnetometer=()",
"microphone=()",
"midi=()",
"navigation-override=()",
"payment=()",
"picture-in-picture=()",
"publickey-credentials-get=()",
"screen-wake-lock=()",
"sync-xhr=()",
"usb=()",
"web-share=()",
"xr-spatial-tracking=()",
}
policy := ""
for i, feature := range features {
if i > 0 {
policy += ", "
}
policy += feature
}
return policy
}
// shouldPreventCaching determines if caching should be prevented for this request
func (m *SecurityHeadersMiddleware) shouldPreventCaching(r *http.Request) bool {
// Always prevent caching for:
// 1. POST, PUT, DELETE, PATCH requests (mutations)
// 2. Authenticated requests (contain sensitive data)
// 3. API endpoints (contain sensitive data)
// Check HTTP method
if r.Method != "GET" && r.Method != "HEAD" {
return true
}
// Check for authentication headers (JWT or API Key)
if r.Header.Get("Authorization") != "" {
return true
}
// Check if it's an API endpoint (all our endpoints start with /api/)
if len(r.URL.Path) >= 5 && r.URL.Path[:5] == "/api/" {
return true
}
// Health check can be cached briefly
if r.URL.Path == "/health" {
return false
}
// Default: prevent caching for security
return true
}