mirror of
https://git.vectorsigma.ru/public/headscale.git
synced 2026-08-05 02:28:06 +00:00
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:
committed by
Kristoffer Dalby
parent
0ce3356b89
commit
5b6e1e17be
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user