diff --git a/internal/server/server.go b/internal/server/server.go index 6866f83..f791276 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 } @@ -1309,6 +1319,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 82037d1..8d44cca 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -612,3 +612,47 @@ func TestOversizedJSONBodyRejected(t *testing.T) { t.Errorf("expected 400 for oversized body, got %d", rr.Code) } } + +// 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) + } + } + }) +}