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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user