diff --git a/hscontrol/handlers.go b/hscontrol/handlers.go index 242673b3..6ccfa611 100644 --- a/hscontrol/handlers.go +++ b/hscontrol/handlers.go @@ -19,17 +19,6 @@ import ( ) const ( - // NoiseCapabilityVersion is used by Tailscale clients to indicate - // their codebase version. Tailscale clients can communicate over TS2021 - // from CapabilityVersion 28, but we only have good support for it - // since https://github.com/tailscale/tailscale/pull/4323 (Noise in any HTTPS port). - // - // Related to this change, there is https://github.com/tailscale/tailscale/pull/5379, - // where CapabilityVersion 39 is introduced to indicate #4323 was merged. - // - // See also https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go - NoiseCapabilityVersion = 39 - reservedResponseHeaderSize = 4 ) @@ -199,21 +188,28 @@ func (h *Headscale) KeyHandler( return } - // TS2021 (Tailscale v2 protocol) requires to have a different key - if capVer >= NoiseCapabilityVersion { - resp := tailcfg.OverTLSPublicKeyResponse{ - PublicKey: h.noisePrivateKey.Public(), - } - - writer.Header().Set("Content-Type", "application/json") - - err := json.NewEncoder(writer).Encode(resp) - if err != nil { - log.Error().Err(err).Msg("failed to encode public key response") - } - + // Only disclose the Noise public key to clients this server can + // actually complete a handshake with. Gating on the same floor the + // Noise handshake enforces (capver.MinSupportedCapabilityVersion, see + // isSupportedVersion in noise.go) keeps /key consistent with /ts2021: + // versions the handshake would reject get a clear rejection here + // instead of a key that only serves as a version-boundary oracle. + // See https://github.com/juanfont/headscale/issues/3380. + if !isSupportedVersion(capVer) { + httpError(writer, NewHTTPError(http.StatusBadRequest, "unsupported client version", unsupportedClientError(capVer))) return } + + resp := tailcfg.OverTLSPublicKeyResponse{ + PublicKey: h.noisePrivateKey.Public(), + } + + writer.Header().Set("Content-Type", "application/json") + + err = json.NewEncoder(writer).Encode(resp) + if err != nil { + log.Error().Err(err).Msg("failed to encode public key response") + } } func (h *Headscale) HealthHandler( diff --git a/hscontrol/handlers_test.go b/hscontrol/handlers_test.go index 9fe315c2..10f851f6 100644 --- a/hscontrol/handlers_test.go +++ b/hscontrol/handlers_test.go @@ -5,12 +5,14 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "net/netip" "strings" "testing" + "github.com/juanfont/headscale/hscontrol/capver" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -116,6 +118,52 @@ func TestVerifyHandler_SuccessSetsJSONContentType(t *testing.T) { "successful /verify response must advertise application/json") } +// TestKeyHandler_UnsupportedCapVerDoesNotLeakKey reproduces +// https://github.com/juanfont/headscale/issues/3380. The /key handler +// must gate key disclosure on the same floor the Noise handshake +// enforces (capver.MinSupportedCapabilityVersion). A capability version +// below that floor can never complete a handshake, so it must be +// rejected rather than handed the server's Noise public key, which would +// otherwise serve only as a fingerprint / version-boundary oracle. +func TestKeyHandler_UnsupportedCapVerDoesNotLeakKey(t *testing.T) { + t.Parallel() + + noise := key.NewMachine() + h := &Headscale{noisePrivateKey: &noise} + + unsupported := capver.MinSupportedCapabilityVersion - 1 + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext( + context.Background(), + http.MethodGet, + fmt.Sprintf("/key?v=%d", unsupported), + nil, + ) + + h.KeyHandler(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code, + "a client below the supported floor must be rejected") + assert.NotContains(t, rec.Body.String(), noise.Public().String(), + "must not disclose Noise public key to a client below the supported floor") + + // A supported client still receives the key. + recOK := httptest.NewRecorder() + reqOK := httptest.NewRequestWithContext( + context.Background(), + http.MethodGet, + fmt.Sprintf("/key?v=%d", capver.MinSupportedCapabilityVersion), + nil, + ) + + h.KeyHandler(recOK, reqOK) + + assert.Equal(t, http.StatusOK, recOK.Code) + assert.Contains(t, recOK.Body.String(), noise.Public().String(), + "a supported client must receive the Noise public key") +} + // errorAsHTTPError is a small local helper that unwraps an [HTTPError] // from an error chain. func errorAsHTTPError(err error) (HTTPError, bool) {