mirror of
https://github.com/juanfont/headscale.git
synced 2026-08-07 07:48:44 +09: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
(cherry picked from commit 5b6e1e17be)
This commit is contained in:
committed by
Kristoffer Dalby
parent
fba84ca232
commit
9609a0b87d
+20
-24
@@ -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
|
||||
)
|
||||
|
||||
@@ -202,21 +191,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(
|
||||
|
||||
@@ -4,12 +4,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/capver"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
var errTestUnexpected = errors.New("unexpected failure")
|
||||
@@ -47,6 +50,52 @@ func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
|
||||
"oversized body must surface 413")
|
||||
}
|
||||
|
||||
// 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