Fix F-04 + F-11; add AUDIT.md

- AUDIT.md: security audit report with 16 findings (F-01..F-16)
- F-04 (server.go): wire loginRateLimit (10 req/s, burst 10) to
  POST /v1/auth/login and POST /v1/token/validate; no limit on
  /v1/health or public-key endpoints
- F-04 (server_test.go): TestLoginRateLimited uses concurrent
  goroutines (sync.WaitGroup) to fire burst+1 requests before
  Argon2id completes, sidestepping token-bucket refill timing;
  TestTokenValidateRateLimited; TestHealthNotRateLimited
- F-11 (ui.go): refactor Register() so all UI routes are mounted
  on a child mux wrapped with securityHeaders middleware; five
  headers set on every response: Content-Security-Policy,
  X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy
- F-11 (ui_test.go): 7 new tests covering login page, dashboard
  redirect, root redirect, static assets, CSP directives,
  HSTS min-age, and middleware unit behaviour
Security: rate limiter on login prevents brute-force credential
  stuffing; security headers mitigate clickjacking (X-Frame-Options
  DENY), MIME sniffing (nosniff), and protocol downgrade (HSTS)
This commit is contained in:
2026-03-11 20:18:09 -07:00
parent 4596ea08ab
commit 4da39475cc
6 changed files with 609 additions and 22 deletions

View File

@@ -153,17 +153,23 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
}, nil
}
// Register attaches all UI routes to mux.
// Register attaches all UI routes to mux, wrapped with security headers.
// All UI responses (pages, fragments, redirects, static assets) carry the
// headers added by securityHeaders.
func (u *UIServer) Register(mux *http.ServeMux) {
// Build a dedicated child mux for all UI routes so that securityHeaders
// can be applied once as a wrapping middleware rather than per-route.
uiMux := http.NewServeMux()
// Static assets — serve from the web/static/ sub-directory of the embed.
staticSubFS, err := fs.Sub(web.StaticFS, "static")
if err != nil {
panic(fmt.Sprintf("ui: static sub-FS: %v", err))
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
uiMux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
// Redirect root to login.
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
uiMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/login", http.StatusFound)
return
@@ -172,9 +178,9 @@ func (u *UIServer) Register(mux *http.ServeMux) {
})
// Auth routes (no session required).
mux.HandleFunc("GET /login", u.handleLoginPage)
mux.HandleFunc("POST /login", u.handleLoginPost)
mux.HandleFunc("POST /logout", u.handleLogout)
uiMux.HandleFunc("GET /login", u.handleLoginPage)
uiMux.HandleFunc("POST /login", u.handleLoginPost)
uiMux.HandleFunc("POST /logout", u.handleLogout)
// Protected routes.
auth := u.requireCookieAuth
@@ -185,19 +191,24 @@ func (u *UIServer) Register(mux *http.ServeMux) {
return auth(http.HandlerFunc(h))
}
mux.Handle("GET /dashboard", adminGet(u.handleDashboard))
mux.Handle("GET /accounts", adminGet(u.handleAccountsList))
mux.Handle("POST /accounts", admin(u.handleCreateAccount))
mux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail))
mux.Handle("PATCH /accounts/{id}/status", admin(u.handleUpdateAccountStatus))
mux.Handle("DELETE /accounts/{id}", admin(u.handleDeleteAccount))
mux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
mux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
mux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
mux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
mux.Handle("GET /audit", adminGet(u.handleAuditPage))
mux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
mux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
uiMux.Handle("GET /dashboard", adminGet(u.handleDashboard))
uiMux.Handle("GET /accounts", adminGet(u.handleAccountsList))
uiMux.Handle("POST /accounts", admin(u.handleCreateAccount))
uiMux.Handle("GET /accounts/{id}", adminGet(u.handleAccountDetail))
uiMux.Handle("PATCH /accounts/{id}/status", admin(u.handleUpdateAccountStatus))
uiMux.Handle("DELETE /accounts/{id}", admin(u.handleDeleteAccount))
uiMux.Handle("GET /accounts/{id}/roles/edit", adminGet(u.handleRolesEditForm))
uiMux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
uiMux.Handle("GET /audit", adminGet(u.handleAuditPage))
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
// on the parent mux continue to take precedence per Go's routing rules.
mux.Handle("/", securityHeaders(uiMux))
}
// ---- Middleware ----
@@ -362,6 +373,34 @@ func (u *UIServer) renderError(w http.ResponseWriter, r *http.Request, status in
// Security: prevents memory exhaustion from oversized POST bodies (gosec G120).
const maxFormBytes = 1 << 20
// securityHeaders returns middleware that adds defensive HTTP headers to every
// UI response.
//
// Security rationale for each header:
// - Content-Security-Policy: restricts resource loading to same origin only,
// mitigating XSS escalation even if an attacker injects HTML (e.g. via a
// malicious stored username rendered in the admin panel).
// - X-Content-Type-Options: prevents MIME-sniffing; browsers must honour the
// declared Content-Type and not guess an executable type from body content.
// - X-Frame-Options: blocks the admin panel from being framed by a third-party
// origin, preventing clickjacking against admin actions.
// - Strict-Transport-Security: instructs browsers to use HTTPS for all future
// requests to this origin for two years, preventing TLS-strip on revisit.
// - Referrer-Policy: suppresses the Referer header on outbound navigations so
// JWTs or session identifiers embedded in URLs are not leaked to third parties.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
h.Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
// clientIP extracts the client IP from RemoteAddr (best effort).
func clientIP(r *http.Request) string {
addr := r.RemoteAddr