debug: refuse requests addressed to unknown host names

This commit is contained in:
Kristoffer Dalby committed 2026-11-02 17:46:54 +00:00
1 parent 9c3deadc9a
commit 9016685a8b
3 files changed
+104 -1

No files matched your search

+5
View File
@@ -71,6 +71,11 @@ Query metrics via <http://localhost:9090/metrics> and get an overview of availab
<http://localhost:9090/debug/>. 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.
+55
View File
@@ -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")
}
})
}
}
+44 -1
View File
@@ -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,
}