Files
pad/internal/server/middleware_security.go
T
xarmian 30fe60d666 feat: session binding, nonce-based CSP, and auth hardening (#75)
* feat: add session binding, nonce-based CSP, and auth hardening

Security hardening for Pad Cloud (PLAN-15 / TASK-171):

- Bind sessions to User-Agent hash; mismatch invalidates session
- Store client IP on session creation for audit trail
- Increase bcrypt cost from 10 to 12
- Upgrade invitation codes to 128-bit entropy with hashed storage
- Replace CSP unsafe-inline with per-request nonce for SvelteKit scripts
- Move SecurityHeaders to main router so SPA gets headers too

* fix: enforce session binding on auth cookie fallbacks and fix invitation code uniqueness

- Add validateSessionCookie() helper that checks UA binding, replacing
  raw ValidateSession() calls in handleSessionCheck, handleGetCurrentUser,
  and handleUpdateCurrentUser that bypassed the new session binding
- Store invitation ID in code column instead of empty string to satisfy
  the NOT NULL UNIQUE constraint (previously broke on second invitation)
- Skip code/join_url in invitation listings for hashed invitations where
  the plaintext is not recoverable
2026-04-08 13:58:58 -04:00

74 lines
2.4 KiB
Go

package server
import (
"crypto/rand"
"encoding/base64"
"net/http"
"strings"
)
// SecurityHeaders adds standard security headers to all responses.
// These protect against common web vulnerabilities like XSS, clickjacking,
// and MIME type sniffing.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Prevent the browser from MIME-sniffing the content type
h.Set("X-Content-Type-Options", "nosniff")
// Prevent the page from being embedded in frames (clickjacking protection)
h.Set("X-Frame-Options", "DENY")
// Control referrer information sent with requests
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Restrict browser features the app doesn't need
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
// CSP: strict policy for API responses. HTML pages served by spaHandler
// override this with a nonce-based script-src for SvelteKit inline scripts.
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
// StrictTransportSecurity adds HSTS header when secure cookies are enabled
// (indicating the server is behind TLS).
func StrictTransportSecurity(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
// generateCSPNonce generates a cryptographically random nonce for
// Content-Security-Policy headers. Returns a 16-byte base64-encoded string.
func generateCSPNonce() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}
// parseCORSOrigins parses a comma-separated list of origins into a slice.
// Returns default localhost origins if the input is empty.
func parseCORSOrigins(origins string) []string {
if origins == "" {
return []string{"http://localhost:*", "http://127.0.0.1:*"}
}
var result []string
for _, origin := range strings.Split(origins, ",") {
origin = strings.TrimSpace(origin)
if origin != "" {
result = append(result, origin)
}
}
if len(result) == 0 {
return []string{"http://localhost:*", "http://127.0.0.1:*"}
}
return result
}