Merge SEC-04: API security headers

This commit is contained in:
2026-03-13 00:50:27 -07:00
2 changed files with 69 additions and 1 deletions

View File

@@ -154,10 +154,20 @@ func (s *Server) Handler() http.Handler {
} }
uiSrv.Register(mux) 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). // Rate limiting is applied per-route above (login, token/validate).
var root http.Handler = mux var root http.Handler = mux
root = middleware.RequestLogger(s.logger)(root) 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 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 // docsSecurityHeaders adds the same defensive HTTP headers as the UI sub-mux
// to the /docs and /docs/openapi.yaml endpoints. // 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 // Security (DEF-09): without these headers the Swagger UI HTML page is
// served without CSP, X-Frame-Options, or HSTS, leaving it susceptible // served without CSP, X-Frame-Options, or HSTS, leaving it susceptible
// to clickjacking and MIME-type confusion in browsers. // to clickjacking and MIME-type confusion in browsers.

View File

@@ -612,3 +612,47 @@ func TestOversizedJSONBodyRejected(t *testing.T) {
t.Errorf("expected 400 for oversized body, got %d", rr.Code) 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)
}
}
})
}