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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user