mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-26 08:16:50 +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
This commit is contained in:
1 parent
0ce3356b89
commit
5b6e1e17be
2 files changed
+68
-24
No files matched your search
+20
-24
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user