From 5803ddedd35cb49c6b3791443f79c6652f6d32e3 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 17 Oct 2026 11:49:49 +0000 Subject: [PATCH] debug: refuse requests addressed to unknown host names --- docs/ref/debug.md | 5 ++++ hscontrol/app_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++ hscontrol/debug.go | 45 ++++++++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/docs/ref/debug.md b/docs/ref/debug.md index 51eeac0cd..334bdffc2 100644 --- a/docs/ref/debug.md +++ b/docs/ref/debug.md @@ -71,6 +71,11 @@ Query metrics via and get an overview of availab . Metrics may be queried from outside localhost but the debug interface is subject to additional protection despite listening on all interfaces. +The debug interface accepts requests addressed to an IP literal, `localhost`, the host in `server_url`, or the hostname +in `metrics_listen_addr`. Hostnames are case-insensitive and may end in a dot. Other hostnames receive HTTP 403; use an +IP address or one of the accepted names. `dns.base_domain` does not add accepted hosts. This check covers all debug +paths, including statsviz. `/metrics` accepts any Host. + === "Direct access" Access the debug interface directly on the server where Headscale is installed. diff --git a/hscontrol/app_test.go b/hscontrol/app_test.go index 75397b5f9..6b0379884 100644 --- a/hscontrol/app_test.go +++ b/hscontrol/app_test.go @@ -24,3 +24,58 @@ func TestSecurityHeaders(t *testing.T) { assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options")) assert.Equal(t, "no-referrer", h.Get("Referrer-Policy")) } + +func TestDebugHostGuard(t *testing.T) { + app := createTestApp(t) + app.cfg.ServerURL = "https://Control.Example.COM:443/headscale" + app.cfg.MetricsAddr = "Debug.Internal:9090" + app.cfg.BaseDomain = "tailnet.invalid" + handler := app.debugHTTPServer().Handler + + for _, tt := range []struct { + name string + host string + path string + status int + }{ + {name: "localhost", host: "localhost:9090", path: "/debug/", status: http.StatusOK}, + {name: "localhost case and dot", host: "LOCALhost.", path: "/debug/", status: http.StatusOK}, + {name: "IPv4", host: "127.0.0.1:9090", path: "/debug/", status: http.StatusOK}, + {name: "IPv6", host: "[::1]", path: "/debug/", status: http.StatusOK}, + {name: "IPv6 port", host: "[::1]:9090", path: "/debug/", status: http.StatusOK}, + {name: "tailnet IP", host: "100.64.0.1:9090", path: "/debug/", status: http.StatusOK}, + {name: "private IP", host: "192.168.0.1:9090", path: "/debug/", status: http.StatusOK}, + {name: "server URL host", host: "CoNtRoL.ExAmPlE.CoM.:9090", path: "/debug/", status: http.StatusOK}, + {name: "metrics listen host", host: "DEBUG.INTERNAL.:9090", path: "/debug/", status: http.StatusOK}, + {name: "unknown host", host: "unknown.example", path: "/debug/", status: http.StatusForbidden}, + {name: "localhost suffix", host: "localhost.remote.example", path: "/debug/", status: http.StatusForbidden}, + {name: "server suffix", host: "control.example.com.remote", path: "/debug/", status: http.StatusForbidden}, + {name: "empty host", host: "", path: "/debug/", status: http.StatusForbidden}, + {name: "base domain", host: "tailnet.invalid", path: "/debug/", status: http.StatusForbidden}, + {name: "base domain child", host: "node.tailnet.invalid", path: "/debug/", status: http.StatusForbidden}, + {name: "custom debug handler", host: "unknown.example", path: "/debug/overview", status: http.StatusForbidden}, + {name: "pprof", host: "unknown.example", path: "/debug/pprof/", status: http.StatusForbidden}, + {name: "statsviz", host: "unknown.example", path: "/debug/statsviz/", status: http.StatusForbidden}, + {name: "statsviz websocket", host: "unknown.example", path: "/debug/statsviz/ws", status: http.StatusForbidden}, + {name: "whole mux", host: "unknown.example", path: "/", status: http.StatusForbidden}, + {name: "metrics any host", host: "unknown.example", path: "/metrics", status: http.StatusOK}, + {name: "metrics empty host", host: "", path: "/metrics", status: http.StatusOK}, + {name: "metrics query", host: "unknown.example", path: "/metrics?x=1", status: http.StatusOK}, + {name: "metrics slash", host: "unknown.example", path: "/metrics/", status: http.StatusForbidden}, + } { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tt.path, nil) + req.Host = tt.host + req.RemoteAddr = "127.0.0.1:1234" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, tt.status, rec.Code) + assert.Equal(t, "DENY", rec.Header().Get("X-Frame-Options")) + + if tt.status == http.StatusForbidden { + assert.Contains(t, rec.Body.String(), "localhost") + } + }) + } +} diff --git a/hscontrol/debug.go b/hscontrol/debug.go index cc2a360bf..44eb4e10d 100644 --- a/hscontrol/debug.go +++ b/hscontrol/debug.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/netip" + "net/url" "slices" "strings" "time" @@ -77,6 +78,48 @@ func writeDebug(w http.ResponseWriter, r *http.Request, jsonVal func() any, text _, _ = w.Write([]byte(textVal())) } +func (h *Headscale) debugHostGuard(next http.Handler) http.Handler { + normalize := func(host string) string { + name, _, err := net.SplitHostPort(host) + if err == nil { + host = name + } + + return strings.TrimSuffix(strings.ToLower(strings.Trim(host, "[]")), ".") + } + + allowed := []string{"localhost", normalize(h.cfg.MetricsAddr)} + + serverURL, err := url.Parse(h.cfg.ServerURL) + if err == nil { + allowed = append(allowed, normalize(serverURL.Hostname())) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/metrics" { + next.ServeHTTP(w, r) + + return + } + + host := normalize(r.Host) + + _, err := netip.ParseAddr(host) + if host != "" && (err == nil || slices.Contains(allowed, host)) { + next.ServeHTTP(w, r) + + return + } + + http.Error( + w, + "debug host not allowed; use localhost, an IP address, "+ + "or the server_url or metrics_listen_addr host", + http.StatusForbidden, + ) + }) +} + func (h *Headscale) debugHTTPServer() *http.Server { debugMux := http.NewServeMux() debug := tsweb.Debugger(debugMux) @@ -224,7 +267,7 @@ func (h *Headscale) debugHTTPServer() *http.Server { debugHTTPServer := &http.Server{ Addr: h.cfg.MetricsAddr, - Handler: securityHeaders(debugMux), + Handler: securityHeaders(h.debugHostGuard(debugMux)), ReadTimeout: types.HTTPTimeout, WriteTimeout: 0, }