From 3f09d5eb4fd8ff195137e1c008bf7bf214e85bb1 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 13 Mar 2026 00:41:48 -0700 Subject: [PATCH] Fix SEC-04: add security headers to API - Add globalSecurityHeaders middleware wrapping root handler - Sets X-Content-Type-Options, Strict-Transport-Security, Cache-Control on all responses (API and UI) - Add tests verifying headers on /v1/health and /v1/auth/login Security: API responses previously lacked HSTS, nosniff, and cache-control headers. The new middleware applies these universally. Headers are safe for all content types and do not conflict with the UI's existing securityHeaders middleware. Co-Authored-By: Claude Opus 4.6 --- internal/server/server.go | 26 +++++++++++++++++++- internal/server/server_test.go | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/internal/server/server.go b/internal/server/server.go index aa135a1..854cbc9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -154,10 +154,20 @@ func (s *Server) Handler() http.Handler { } uiSrv.Register(mux) - // Apply global middleware: request logging. + // Apply global middleware: request logging and security headers. // Rate limiting is applied per-route above (login, token/validate). var root http.Handler = mux root = middleware.RequestLogger(s.logger)(root) + + // Security (SEC-04): apply baseline security headers to ALL responses + // (both API and UI). These headers are safe for every content type: + // - X-Content-Type-Options prevents MIME-sniffing attacks. + // - Strict-Transport-Security enforces HTTPS for 2 years. + // - Cache-Control prevents caching of authenticated responses. + // The UI sub-mux already sets these plus CSP/X-Frame-Options/Referrer-Policy + // which will override where needed (last Set wins before WriteHeader). + root = globalSecurityHeaders(root) + return root } @@ -1297,6 +1307,20 @@ func extractBearerFromRequest(r *http.Request) (string, error) { // docsSecurityHeaders adds the same defensive HTTP headers as the UI sub-mux // to the /docs and /docs/openapi.yaml endpoints. // +// globalSecurityHeaders sets baseline security headers on every response. +// Security (SEC-04): API responses previously lacked X-Content-Type-Options, +// HSTS, and Cache-Control. These three headers are safe for all content types +// and do not interfere with JSON API clients or the HTMX UI. +func globalSecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + h.Set("Cache-Control", "no-store") + next.ServeHTTP(w, r) + }) +} + // Security (DEF-09): without these headers the Swagger UI HTML page is // served without CSP, X-Frame-Options, or HSTS, leaving it susceptible // to clickjacking and MIME-type confusion in browsers. diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f7325fe..3780164 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -594,3 +594,47 @@ func TestRenewToken(t *testing.T) { t.Error("old token should be revoked after renewal") } } + +// TestSecurityHeadersOnAPIResponses verifies that the global security-headers +// middleware (SEC-04) sets X-Content-Type-Options, Strict-Transport-Security, +// and Cache-Control on all API responses, not just the UI. +func TestSecurityHeadersOnAPIResponses(t *testing.T) { + srv, _, _, _ := newTestServer(t) + handler := srv.Handler() + + wantHeaders := map[string]string{ + "X-Content-Type-Options": "nosniff", + "Strict-Transport-Security": "max-age=63072000; includeSubDomains", + "Cache-Control": "no-store", + } + + t.Run("GET /v1/health", func(t *testing.T) { + rr := doRequest(t, handler, "GET", "/v1/health", nil, "") + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + for header, want := range wantHeaders { + got := rr.Header().Get(header) + if got != want { + t.Errorf("%s = %q, want %q", header, got, want) + } + } + }) + + t.Run("POST /v1/auth/login", func(t *testing.T) { + createTestHumanAccount(t, srv, "sec04-user") + rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{ + "username": "sec04-user", + "password": "testpass123", + }, "") + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + for header, want := range wantHeaders { + got := rr.Header().Get(header) + if got != want { + t.Errorf("%s = %q, want %q", header, got, want) + } + } + }) +}