hscontrol: gate /key on supported capability version

/key handed out the Noise public key for any v>=39, a floor unrelated
to the handshake's capver.MinSupportedCapabilityVersion. Reject below
the supported floor, matching /ts2021, and drop the stale constant.

Fixes #3380
This commit is contained in:
Kristoffer Dalby authored and Kristoffer Dalby committed 2026-07-28 11:54:46 +02:00
1 parent 0ce3356b89
commit 5b6e1e17be
2 files changed
+68 -24

No files matched your search

+20 -24
View File
@@ -19,17 +19,6 @@ import (
) )
const ( 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 reservedResponseHeaderSize = 4
) )
@@ -199,21 +188,28 @@ func (h *Headscale) KeyHandler(
return return
} }
// TS2021 (Tailscale v2 protocol) requires to have a different key // Only disclose the Noise public key to clients this server can
if capVer >= NoiseCapabilityVersion { // actually complete a handshake with. Gating on the same floor the
resp := tailcfg.OverTLSPublicKeyResponse{ // Noise handshake enforces (capver.MinSupportedCapabilityVersion, see
PublicKey: h.noisePrivateKey.Public(), // 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.
writer.Header().Set("Content-Type", "application/json") // See https://github.com/juanfont/headscale/issues/3380.
if !isSupportedVersion(capVer) {
err := json.NewEncoder(writer).Encode(resp) httpError(writer, NewHTTPError(http.StatusBadRequest, "unsupported client version", unsupportedClientError(capVer)))
if err != nil {
log.Error().Err(err).Msg("failed to encode public key response")
}
return 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( func (h *Headscale) HealthHandler(
+48
View File
@@ -5,12 +5,14 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"strings" "strings"
"testing" "testing"
"github.com/juanfont/headscale/hscontrol/capver"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -116,6 +118,52 @@ func TestVerifyHandler_SuccessSetsJSONContentType(t *testing.T) {
"successful /verify response must advertise application/json") "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] // errorAsHTTPError is a small local helper that unwraps an [HTTPError]
// from an error chain. // from an error chain.
func errorAsHTTPError(err error) (HTTPError, bool) { func errorAsHTTPError(err error) (HTTPError, bool) {