mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 04:34:07 +09:00
auth, state: keep announced routes out of pending registrations
Routes are the one long list; the first MapRequest reports them. A relogin keeps the node's own routes until then. Fixes ANT-2026-RQS7JAPP
This commit is contained in:
1 parent
c87672bcf6
commit
3c277949c7
6 files changed
+103
-20
No files matched your search
@@ -155,6 +155,7 @@ the ACME DNS-01 challenge through a built-in DNS provider: `acmedns`,
|
||||
- Refuse a key expiry outside the years 0 to 9999 in UTC on registration, on the OIDC token-expiry path and in `headscale nodes expire` / `POST /api/v1/node/{nodeId}/expire` (400); the latest accepted expiry no longer fails node listings on PostgreSQL in a zone east of UTC
|
||||
- Bound what a client reports: Hostinfo lists and endpoints over 256 entries, announced routes over 4096 and hostnames over 255 bytes are cut, and a Hostinfo whose NetInfo or other fields encode to more than 64 KiB is refused
|
||||
- Nodes stored with reports beyond these bounds load with the bounded values; a stored Hostinfo still over the size limit is dropped until the node reports again
|
||||
- A pending interactive registration no longer stores announced routes; a new node's routes, and their auto-approval, arrive with its first map request
|
||||
|
||||
## 0.29.6 (2026-10-24)
|
||||
|
||||
|
||||
+14
-6
@@ -389,24 +389,32 @@ func (h *Headscale) reqToNewRegisterResponse(
|
||||
}
|
||||
|
||||
// registrationDataFromRequest builds the [types.RegistrationData] payload stored
|
||||
// in the auth cache for a pending registration. The original [tailcfg.Hostinfo] is
|
||||
// retained so that consumers (auth callback, observability) see the
|
||||
// fields the client originally announced; the bounded-LRU cap on the
|
||||
// cache is what bounds the unauthenticated cache-fill DoS surface.
|
||||
// in the auth cache for a pending registration. The [tailcfg.Hostinfo] is kept
|
||||
// so that consumers (auth callback, observability) see the fields the client
|
||||
// announced, except its routes: they are the one list a real client makes
|
||||
// long, and the node's first [tailcfg.MapRequest] reports them again.
|
||||
func registrationDataFromRequest(
|
||||
req tailcfg.RegisterRequest,
|
||||
machineKey key.MachinePublic,
|
||||
) *types.RegistrationData {
|
||||
var hostname string
|
||||
var (
|
||||
hostname string
|
||||
hostinfo *tailcfg.Hostinfo
|
||||
)
|
||||
|
||||
if req.Hostinfo != nil {
|
||||
hostname = req.Hostinfo.Hostname
|
||||
|
||||
hi := *req.Hostinfo
|
||||
hi.RoutableIPs = nil
|
||||
hostinfo = &hi
|
||||
}
|
||||
|
||||
regData := &types.RegistrationData{
|
||||
MachineKey: machineKey,
|
||||
NodeKey: req.NodeKey,
|
||||
Hostname: hostname,
|
||||
Hostinfo: req.Hostinfo,
|
||||
Hostinfo: hostinfo,
|
||||
}
|
||||
|
||||
if !req.Expiry.IsZero() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -4598,3 +4599,34 @@ func TestRegisterRejectsUnencodableExpiry(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.True(t, latest.Equal(*stored.Expiry))
|
||||
}
|
||||
|
||||
// TestPendingRegistrationKeepsNoRoutes pins that a pending registration caches
|
||||
// the client's Hostinfo without its announced routes, the one list a client
|
||||
// makes long; the node's first MapRequest reports them again.
|
||||
func TestPendingRegistrationKeepsNoRoutes(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
hostinfo := &tailcfg.Hostinfo{
|
||||
Hostname: "pending-router",
|
||||
OSVersion: "6.12.0",
|
||||
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
|
||||
Services: []tailcfg.Service{{Proto: tailcfg.PeerAPI4, Port: 41641}},
|
||||
}
|
||||
|
||||
resp, err := app.handleRegister(context.Background(), tailcfg.RegisterRequest{
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostinfo: hostinfo,
|
||||
}, key.NewMachine().Public())
|
||||
require.NoError(t, err)
|
||||
|
||||
authID, err := extractRegistrationIDFromAuthURL(resp.AuthURL)
|
||||
require.NoError(t, err, "resp=%+v", resp)
|
||||
|
||||
entry, ok := app.state.GetAuthCacheEntry(authID)
|
||||
require.True(t, ok)
|
||||
|
||||
want := hostinfo.Clone()
|
||||
want.RoutableIPs = nil
|
||||
assert.Equal(t, want, entry.RegistrationData().Hostinfo)
|
||||
assert.Len(t, hostinfo.RoutableIPs, 1, "the request itself must be left alone")
|
||||
}
|
||||
@@ -1491,3 +1491,43 @@ func TestBootKeepsHostinfoTheMapPathStored(t *testing.T) {
|
||||
osVersionLen := len(reloaded.Hostinfo().OSVersion())
|
||||
assert.Equal(t, 60<<10, osVersionLen)
|
||||
}
|
||||
|
||||
// TestReauthKeepsAnnouncedRoutes pins that an auth-path relogin keeps the
|
||||
// routes a router announced. Pending registrations carry none, and dropping
|
||||
// the node's own would withdraw its primary routes until the next MapRequest.
|
||||
func TestReauthKeepsAnnouncedRoutes(t *testing.T) {
|
||||
_, s, nodeID := persistTestSetup(t)
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
route := netip.MustParsePrefix("10.0.0.0/24")
|
||||
|
||||
node, ok := s.nodeStore.UpdateNode(nodeID, func(n *types.Node) {
|
||||
n.Hostinfo = &tailcfg.Hostinfo{
|
||||
Hostname: n.Hostname, RoutableIPs: []netip.Prefix{route},
|
||||
}
|
||||
n.ApprovedRoutes = []netip.Prefix{route}
|
||||
})
|
||||
require.True(t, ok)
|
||||
|
||||
_, epoch := s.Connect(nodeID)
|
||||
require.NotZero(t, epoch)
|
||||
require.Equal(t, []netip.Prefix{route}, s.GetNodePrimaryRoutes(nodeID),
|
||||
"precondition: the node serves its route")
|
||||
|
||||
authID := types.MustAuthID()
|
||||
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
MachineKey: node.MachineKey(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: node.Hostname(),
|
||||
Hostinfo: &tailcfg.Hostinfo{Hostname: node.Hostname()},
|
||||
}))
|
||||
|
||||
relogged, _, err := s.HandleNodeFromAuthPath(
|
||||
authID, node.TypedUserID(), nil, util.RegisterMethodOIDC,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, nodeID, relogged.ID(), "relogin must update the existing node")
|
||||
|
||||
assert.Equal(t, []netip.Prefix{route}, relogged.AnnouncedRoutes())
|
||||
assert.Equal(t, []netip.Prefix{route}, s.GetNodePrimaryRoutes(nodeID))
|
||||
}
|
||||
@@ -50,10 +50,9 @@ const (
|
||||
registerCacheExpiration = time.Minute * 15
|
||||
|
||||
// defaultRegisterCacheMaxEntries is the default upper bound on the number
|
||||
// of pending registration entries the auth cache will hold. With a 15-minute
|
||||
// TTL and a stripped-down RegistrationData payload (~200 bytes per entry),
|
||||
// 1024 entries cap the worst-case cache footprint at well under 1 MiB even
|
||||
// under sustained unauthenticated cache-fill attempts.
|
||||
// of pending registration entries the auth cache will hold. Each entry
|
||||
// keeps a Hostinfo bounded at decode ([types.MaxHostinfoSize]) and
|
||||
// without routes, so the entry cap bounds the cache's memory.
|
||||
defaultRegisterCacheMaxEntries = 1024
|
||||
|
||||
// defaultNodeStoreBatchSize is the default number of write operations to batch
|
||||
@@ -1982,6 +1981,10 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
|
||||
node.DiscoKey = regData.DiscoKey
|
||||
node.Hostname = params.Hostname
|
||||
|
||||
// Pending registrations do not cache announced routes; keep the
|
||||
// node's own until its next MapRequest reports them.
|
||||
routes := slices.Clone(node.AnnouncedRoutes())
|
||||
|
||||
// Preserve NetInfo from existing node when re-registering
|
||||
node.Hostinfo = params.ValidHostinfo
|
||||
node.Hostinfo.NetInfo = preserveNetInfo(
|
||||
@@ -1989,6 +1992,7 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
|
||||
params.ExistingNode.ID(),
|
||||
params.ValidHostinfo,
|
||||
)
|
||||
node.Hostinfo.RoutableIPs = routes
|
||||
|
||||
// Preserve the node's live endpoints when the register request carried
|
||||
// none. Web/OIDC relogins report endpoints via MapRequest, not register,
|
||||
|
||||
@@ -13,11 +13,9 @@ import (
|
||||
// only the fields the registration callback path actually consumes when
|
||||
// promoting a pending registration to a real node.
|
||||
//
|
||||
// Combined with the bounded-LRU cache that holds these entries, this caps
|
||||
// the worst-case memory footprint of unauthenticated cache-fill attempts
|
||||
// at (max_entries × per_entry_size). The cache is sized so that the
|
||||
// product is bounded to a few MiB even with attacker-supplied 1 MiB
|
||||
// Hostinfos (the Noise body limit).
|
||||
// Any client can open a pending registration. The bounded-LRU cache that
|
||||
// holds these entries caps their number, and each entry stays small: its
|
||||
// Hostinfo is bounded at decode ([BoundHostinfo]) and kept without routes.
|
||||
type RegistrationData struct {
|
||||
// MachineKey is the cryptographic identity of the machine being
|
||||
// registered. Required.
|
||||
@@ -34,11 +32,11 @@ type RegistrationData struct {
|
||||
// Already validated/normalised by EnsureHostname at producer time.
|
||||
Hostname string
|
||||
|
||||
// Hostinfo is the original [tailcfg.Hostinfo] from the [tailcfg.RegisterRequest],
|
||||
// stored so that the auth callback can populate the new node's
|
||||
// initial [tailcfg.Hostinfo] (and so that observability/CLI consumers see
|
||||
// fields like OS, OSVersion, and IPNVersion before the first
|
||||
// [tailcfg.MapRequest] restores the live set).
|
||||
// Hostinfo is the [tailcfg.Hostinfo] from the [tailcfg.RegisterRequest]
|
||||
// without its routes, stored so that the auth callback can populate the
|
||||
// new node's initial [tailcfg.Hostinfo] (and so that observability/CLI
|
||||
// consumers see fields like OS, OSVersion, and IPNVersion before the
|
||||
// first [tailcfg.MapRequest] restores the live set, routes included).
|
||||
//
|
||||
// May be nil if the client did not send [tailcfg.Hostinfo] in the original
|
||||
// [tailcfg.RegisterRequest].
|
||||
|
||||
Reference in new issue
Block a user