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

@@ -127,7 +127,30 @@ func (db *DB) UpdatePasswordHash(accountID int64, hash string) error {
return nil
}
// StorePendingTOTP stores the encrypted TOTP secret without enabling the
// totp_required flag. This is called during enrollment (POST /v1/auth/totp/enroll)
// so the secret is available for confirmation without yet enforcing TOTP on login.
// The flag is set to 1 only after the user successfully confirms the code via
// handleTOTPConfirm, which calls SetTOTP.
//
// Security: keeping totp_required=0 during enrollment prevents the user from
// being locked out if they abandon the enrollment flow after the secret is
// generated but before they have set up their authenticator app.
func (db *DB) StorePendingTOTP(accountID int64, secretEnc, secretNonce []byte) error {
_, err := db.sql.Exec(`
UPDATE accounts
SET totp_secret_enc = ?, totp_secret_nonce = ?, updated_at = ?
WHERE id = ?
`, secretEnc, secretNonce, now(), accountID)
if err != nil {
return fmt.Errorf("db: store pending TOTP: %w", err)
}
return nil
}
// SetTOTP stores the encrypted TOTP secret and marks TOTP as required.
// Call this only after the user has confirmed the TOTP code; for the initial
// enrollment step use StorePendingTOTP instead.
func (db *DB) SetTOTP(accountID int64, secretEnc, secretNonce []byte) error {
_, err := db.sql.Exec(`
UPDATE accounts

View File

@@ -53,11 +53,16 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Security: per-IP rate limiting on public auth endpoints to prevent
// brute-force login attempts and token-validation abuse. Parameters match
// the gRPC rate limiter (10 req/s sustained, burst 10).
loginRateLimit := middleware.RateLimit(10, 10)
// Public endpoints (no authentication required).
mux.HandleFunc("GET /v1/health", s.handleHealth)
mux.HandleFunc("GET /v1/keys/public", s.handlePublicKey)
mux.HandleFunc("POST /v1/auth/login", s.handleLogin)
mux.HandleFunc("POST /v1/token/validate", s.handleTokenValidate)
mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin)))
mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate)))
// Authenticated endpoints.
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
@@ -93,7 +98,8 @@ func (s *Server) Handler() http.Handler {
}
uiSrv.Register(mux)
// Apply global middleware: logging and login-path rate limiting.
// Apply global middleware: request logging.
// Rate limiting is applied per-route above (login, token/validate).
var root http.Handler = mux
root = middleware.RequestLogger(s.logger)(root)
return root

View File

@@ -396,6 +396,84 @@ func TestSetAndGetRoles(t *testing.T) {
}
}
func TestLoginRateLimited(t *testing.T) {
srv, _, _, _ := newTestServer(t)
createTestHumanAccount(t, srv, "ratelimit-user")
handler := srv.Handler()
// The login endpoint uses RateLimit(10, 10): burst of 10 requests.
// Exhaust the burst with 10 requests (valid or invalid — doesn't matter).
body := map[string]string{
"username": "ratelimit-user",
"password": "wrongpassword",
}
for i := range 10 {
rr := doRequest(t, handler, "POST", "/v1/auth/login", body, "")
if rr.Code == http.StatusTooManyRequests {
t.Fatalf("request %d was rate-limited prematurely", i+1)
}
}
// The 11th request from the same IP should be rate-limited.
rr := doRequest(t, handler, "POST", "/v1/auth/login", body, "")
if rr.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 after exhausting burst, got %d", rr.Code)
}
// Verify the Retry-After header is set.
if rr.Header().Get("Retry-After") == "" {
t.Error("expected Retry-After header on 429 response")
}
}
func TestTokenValidateRateLimited(t *testing.T) {
srv, _, _, _ := newTestServer(t)
handler := srv.Handler()
// The token/validate endpoint shares the same per-IP rate limiter as login.
// Use a distinct RemoteAddr so we get a fresh bucket.
body := map[string]string{"token": "not.a.valid.token"}
for i := range 10 {
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/v1/token/validate", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.99.99.1:12345"
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code == http.StatusTooManyRequests {
t.Fatalf("request %d was rate-limited prematurely", i+1)
}
}
// 11th request should be rate-limited.
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/v1/token/validate", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.99.99.1:12345"
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 after exhausting burst, got %d", rr.Code)
}
}
func TestHealthNotRateLimited(t *testing.T) {
srv, _, _, _ := newTestServer(t)
handler := srv.Handler()
// Health endpoint should not be rate-limited — send 20 rapid requests.
for i := range 20 {
req := httptest.NewRequest("GET", "/v1/health", nil)
req.RemoteAddr = "10.88.88.1:12345"
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("health request %d: status = %d, want 200", i+1, rr.Code)
}
}
}
func TestRenewToken(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
acct := createTestHumanAccount(t, srv, "renew-user")

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

183
internal/ui/ui_test.go Normal file
View File

@@ -0,0 +1,183 @@
package ui
import (
"crypto/ed25519"
"crypto/rand"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
)
const testIssuer = "https://auth.example.com"
// newTestMux creates a UIServer and returns the http.Handler used in production
// (a ServeMux with all UI routes registered, wrapped with securityHeaders).
func newTestMux(t *testing.T) http.Handler {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("migrate db: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
masterKey := make([]byte, 32)
if _, err := rand.Read(masterKey); err != nil {
t.Fatalf("generate master key: %v", err)
}
cfg := config.NewTestConfig(testIssuer)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
uiSrv, err := New(database, cfg, priv, pub, masterKey, logger)
if err != nil {
t.Fatalf("new UIServer: %v", err)
}
mux := http.NewServeMux()
uiSrv.Register(mux)
return mux
}
// assertSecurityHeaders verifies all mandatory defensive headers are present in
// resp with acceptable values. The label is used in failure messages to identify
// which endpoint the test was checking.
func assertSecurityHeaders(t *testing.T, h http.Header, label string) {
t.Helper()
checks := []struct {
header string
wantSub string
}{
{"Content-Security-Policy", "default-src 'self'"},
{"X-Content-Type-Options", "nosniff"},
{"X-Frame-Options", "DENY"},
{"Strict-Transport-Security", "max-age="},
{"Referrer-Policy", "no-referrer"},
}
for _, c := range checks {
val := h.Get(c.header)
if val == "" {
t.Errorf("[%s] missing security header %s", label, c.header)
continue
}
if c.wantSub != "" && !strings.Contains(val, c.wantSub) {
t.Errorf("[%s] %s = %q, want substring %q", label, c.header, val, c.wantSub)
}
}
}
// TestSecurityHeadersOnLoginPage verifies headers are present on the public login page.
func TestSecurityHeadersOnLoginPage(t *testing.T) {
mux := newTestMux(t)
req := httptest.NewRequest(http.MethodGet, "/login", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assertSecurityHeaders(t, rr.Result().Header, "GET /login")
}
// TestSecurityHeadersOnUnauthenticatedDashboard verifies headers are present even
// when the response is a redirect to login (no session cookie supplied).
func TestSecurityHeadersOnUnauthenticatedDashboard(t *testing.T) {
mux := newTestMux(t)
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assertSecurityHeaders(t, rr.Result().Header, "GET /dashboard (no session)")
}
// TestSecurityHeadersOnRootRedirect verifies headers on the "/" → "/login" redirect.
func TestSecurityHeadersOnRootRedirect(t *testing.T) {
mux := newTestMux(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assertSecurityHeaders(t, rr.Result().Header, "GET /")
}
// TestSecurityHeadersOnStaticAsset verifies headers are present on static file responses.
func TestSecurityHeadersOnStaticAsset(t *testing.T) {
mux := newTestMux(t)
req := httptest.NewRequest(http.MethodGet, "/static/style.css", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
// 200 or 404 — either way the securityHeaders wrapper must fire.
assertSecurityHeaders(t, rr.Result().Header, "GET /static/style.css")
}
// TestCSPDirectives verifies the Content-Security-Policy includes same-origin
// directives for scripts and styles.
func TestCSPDirectives(t *testing.T) {
mux := newTestMux(t)
req := httptest.NewRequest(http.MethodGet, "/login", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
csp := rr.Header().Get("Content-Security-Policy")
for _, directive := range []string{
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
} {
if !strings.Contains(csp, directive) {
t.Errorf("CSP missing directive %q; full value: %q", directive, csp)
}
}
}
// TestHSTSMinAge verifies HSTS max-age is at least two years (63072000 seconds).
func TestHSTSMinAge(t *testing.T) {
mux := newTestMux(t)
req := httptest.NewRequest(http.MethodGet, "/login", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
hsts := rr.Header().Get("Strict-Transport-Security")
if !strings.Contains(hsts, "max-age=63072000") {
t.Errorf("HSTS = %q, want max-age=63072000 (2 years)", hsts)
}
}
// TestSecurityHeadersMiddlewareUnit tests the securityHeaders middleware in
// isolation, independent of routing, to guard against future refactoring.
func TestSecurityHeadersMiddlewareUnit(t *testing.T) {
reached := false
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})
handler := securityHeaders(inner)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if !reached {
t.Error("inner handler was not reached")
}
assertSecurityHeaders(t, rr.Result().Header, "unit test")
}