diff --git a/CHANGELOG.md b/CHANGELOG.md index dddefc373..7f8f551d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/hscontrol/auth.go b/hscontrol/auth.go index 99b8720dd..5820aeb7c 100644 --- a/hscontrol/auth.go +++ b/hscontrol/auth.go @@ -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() { diff --git a/hscontrol/auth_test.go b/hscontrol/auth_test.go index 053471b92..2583431e0 100644 --- a/hscontrol/auth_test.go +++ b/hscontrol/auth_test.go @@ -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") +} diff --git a/hscontrol/state/persist_test.go b/hscontrol/state/persist_test.go index 110d2542a..70a08ed3e 100644 --- a/hscontrol/state/persist_test.go +++ b/hscontrol/state/persist_test.go @@ -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)) +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index fbd86c25f..98931a5e2 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -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, diff --git a/hscontrol/types/registration.go b/hscontrol/types/registration.go index 669114d97..91c9fa640 100644 --- a/hscontrol/types/registration.go +++ b/hscontrol/types/registration.go @@ -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].