From 0567cb6da3a294110bbd27f9b51c48e05938d57b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 17 Apr 2026 05:50:09 +0000 Subject: [PATCH] app: add security headers middleware X-Frame-Options: DENY and frame-ancestors 'none' stop clickjacking of OIDC, register-confirm, and debug HTML pages. nosniff and no-referrer are cheap defence-in-depth for the same surfaces. Updates #3157 --- hscontrol/app.go | 15 +++++++++++++++ hscontrol/app_test.go | 26 ++++++++++++++++++++++++++ hscontrol/debug.go | 2 +- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 hscontrol/app_test.go diff --git a/hscontrol/app.go b/hscontrol/app.go index 49f3cb4a..30134ac6 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -488,6 +488,20 @@ func (h *Headscale) ensureUnixSocketIsAbsent() error { return os.Remove(h.cfg.UnixSocket) } +// securityHeaders sets baseline response headers on every HTTP response: +// deny framing (clickjacking), forbid MIME-type sniffing, drop the Referer +// header on outbound navigation. Cheap defense-in-depth for HTML surfaces. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Frame-Options", "DENY") + h.Set("Content-Security-Policy", "frame-ancestors 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) +} + func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux { r := chi.NewRouter() r.Use(metrics.Collector(metrics.CollectorOpts{ @@ -501,6 +515,7 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux { r.Use(middleware.RealIP) r.Use(middleware.RequestLogger(&zerologRequestLogger{})) r.Use(middleware.Recoverer) + r.Use(securityHeaders) r.Post(ts2021UpgradePath, h.NoiseUpgradeHandler) diff --git a/hscontrol/app_test.go b/hscontrol/app_test.go new file mode 100644 index 00000000..75397b5f --- /dev/null +++ b/hscontrol/app_test.go @@ -0,0 +1,26 @@ +package hscontrol + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSecurityHeaders(t *testing.T) { + handler := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) + handler.ServeHTTP(rec, req) + + h := rec.Result().Header + assert.Equal(t, "DENY", h.Get("X-Frame-Options")) + assert.Equal(t, "frame-ancestors 'none'", h.Get("Content-Security-Policy")) + assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options")) + assert.Equal(t, "no-referrer", h.Get("Referrer-Policy")) +} diff --git a/hscontrol/debug.go b/hscontrol/debug.go index c27b3975..f2b27ca1 100644 --- a/hscontrol/debug.go +++ b/hscontrol/debug.go @@ -379,7 +379,7 @@ func (h *Headscale) debugHTTPServer() *http.Server { debugHTTPServer := &http.Server{ Addr: h.cfg.MetricsAddr, - Handler: debugMux, + Handler: securityHeaders(debugMux), ReadTimeout: types.HTTPTimeout, WriteTimeout: 0, }