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)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1297,6 +1307,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.
|
||||||
|
|||||||
@@ -594,3 +594,47 @@ func TestRenewToken(t *testing.T) {
|
|||||||
t.Error("old token should be revoked after renewal")
|
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