auth, state: keep only hostname, OS and tags in pending registrations

The rest arrives with the first MapRequest. A relogin keeps the node's
current Hostinfo, endpoints and disco key; an oversized merge is a 400.

Fixes ANT-2026-RQS7JAPP
This commit is contained in:
Kristoffer Dalby committed 2026-10-29 14:00:02 +01:00
1 parent 4ce928411e
commit 5a4d366ce1
11 files changed
+375 -91

No files matched your search

+2
View File
@@ -160,6 +160,8 @@ 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 - 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
- Keep the first 256 endpoints and 4096 announced routes from client reports; oversized hostnames, requested tags or other metadata return a registration error or HTTP 413 on map updates. Zoned IPv6 endpoints are refused - Keep the first 256 endpoints and 4096 announced routes from client reports; oversized hostnames, requested tags or other metadata return a registration error or HTTP 413 on map updates. Zoned IPv6 endpoints are refused
- At startup, trim stored hostnames, endpoints and routes to the client-report limits and remove zoned endpoints. Hostinfo exceeding its size or tag limit is dropped until the next valid report - At startup, trim stored hostnames, endpoints and routes to the client-report limits and remove zoned endpoints. Hostinfo exceeding its size or tag limit is dropped until the next valid report
- Pending interactive registrations keep only the hostname, OS and requested tags; other device details arrive with the first map request. Reauthentication preserves routes, services and endpoints
- Web reauthentication preserves the discovery key so peers can still reach the device
## 0.29.7 (202x-xx-xx) ## 0.29.7 (202x-xx-xx)
+1
View File
@@ -35,6 +35,7 @@ func mapError(msg string, err error) error {
errors.Is(err, state.ErrNodeHasNeitherUserNorTags), errors.Is(err, state.ErrNodeHasNeitherUserNorTags),
errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted), errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted),
errors.Is(err, types.ErrExpiryOutOfRange), errors.Is(err, types.ErrExpiryOutOfRange),
errors.Is(err, types.ErrHostinfoTooLarge),
errors.Is(err, db.ErrUserStillHasNodes), errors.Is(err, db.ErrUserStillHasNodes),
errors.Is(err, db.ErrCannotChangeOIDCUser), errors.Is(err, db.ErrCannotChangeOIDCUser),
errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned), errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned),
+15 -7
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"strings" "strings"
"time" "time"
@@ -388,25 +389,32 @@ func (h *Headscale) reqToNewRegisterResponse(
}, nil }, nil
} }
// registrationDataFromRequest builds the [types.RegistrationData] payload stored // registrationDataFromRequest keeps only auth and confirmation metadata;
// in the auth cache for a pending registration. The original [tailcfg.Hostinfo] is // operational metadata arrives with the node's first MapRequest.
// 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.
func registrationDataFromRequest( func registrationDataFromRequest(
req tailcfg.RegisterRequest, req tailcfg.RegisterRequest,
machineKey key.MachinePublic, machineKey key.MachinePublic,
) *types.RegistrationData { ) *types.RegistrationData {
var hostname string var (
hostname string
hostinfo *tailcfg.Hostinfo
)
if req.Hostinfo != nil { if req.Hostinfo != nil {
hostname = req.Hostinfo.Hostname hostname = req.Hostinfo.Hostname
hostinfo = &tailcfg.Hostinfo{
Hostname: hostname,
OS: req.Hostinfo.OS,
RequestTags: slices.Clone(req.Hostinfo.RequestTags),
}
} }
regData := &types.RegistrationData{ regData := &types.RegistrationData{
MachineKey: machineKey, MachineKey: machineKey,
NodeKey: req.NodeKey, NodeKey: req.NodeKey,
Hostname: hostname, Hostname: hostname,
Hostinfo: req.Hostinfo, Hostinfo: hostinfo,
} }
if !req.Expiry.IsZero() { if !req.Expiry.IsZero() {
+59 -18
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/netip"
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
@@ -1638,15 +1639,8 @@ func TestAuthenticationFlows(t *testing.T) {
}, },
// === AUTH PROVIDER EDGE CASES === // === AUTH PROVIDER EDGE CASES ===
// TEST: Interactive workflow preserves custom hostinfo // Interactive registration retains auth metadata. The first map request
// WHAT: Tests that custom hostinfo fields are preserved through interactive flow // supplies the remaining Hostinfo fields.
// INPUT: Interactive registration with detailed hostinfo (OS, version, model)
// EXPECTED: Node registers with all hostinfo fields preserved
// WHY: Ensures interactive flow doesn't lose custom hostinfo data
// NOTE: RequestTags are NOT tested here because tag authorization via
// advertise-tags requires the user to have existing nodes (for IP-based
// ownership verification). New users registering their first node cannot
// claim tags via RequestTags - they must use a tagged PreAuthKey instead.
{ {
name: "interactive_workflow_with_custom_hostinfo", name: "interactive_workflow_with_custom_hostinfo",
setupFunc: func(t *testing.T, app *Headscale) (string, error) { //nolint:thelper setupFunc: func(t *testing.T, app *Headscale) (string, error) { //nolint:thelper
@@ -1673,15 +1667,29 @@ func TestAuthenticationFlows(t *testing.T) {
validateCompleteResponse: true, validateCompleteResponse: true,
expectedAuthURLPattern: "/register/", expectedAuthURLPattern: "/register/",
validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper
// Verify custom hostinfo was preserved through interactive workflow // Confirm the minimal cached report, then restore the operational fields.
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public()) node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(t, found, "node should be found after interactive registration") assert.True(t, found, "node should be found after interactive registration")
if found { if found {
assert.Equal(t, "custom-interactive-node", node.Hostname()) assert.Equal(t, "custom-interactive-node", node.Hostname())
assert.Equal(t, "linux", node.Hostinfo().OS()) assert.Equal(t, "linux", node.Hostinfo().OS())
assert.Equal(t, "20.04", node.Hostinfo().OSVersion()) assert.Empty(t, node.Hostinfo().OSVersion())
assert.Equal(t, "server", node.Hostinfo().DeviceModel()) assert.Empty(t, node.Hostinfo().DeviceModel())
_, err := app.state.UpdateNodeFromMapRequest(node.ID(), tailcfg.MapRequest{
NodeKey: node.NodeKey(), DiscoKey: node.DiscoKey(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: node.Hostname(), OS: "linux",
OSVersion: "20.04", DeviceModel: "server",
},
})
require.NoError(t, err)
updated, ok := app.state.GetNodeByID(node.ID())
require.True(t, ok)
assert.Equal(t, "20.04", updated.Hostinfo().OSVersion())
assert.Equal(t, "server", updated.Hostinfo().DeviceModel())
} }
}, },
}, },
@@ -4340,14 +4348,14 @@ func TestHandleNodeFromAuthPath_ReloginWholeNode(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
expired bool expired bool
services []tailcfg.Service hostname string
wantPatch bool wantPatch bool
}{ }{
{name: "keys only", wantPatch: true}, {name: "keys only", wantPatch: true},
{name: "expired", expired: true}, {name: "expired", expired: true},
{ {
name: "peer-visible hostinfo", name: "peer-visible hostname",
services: []tailcfg.Service{{Proto: tailcfg.PeerAPI4, Port: 4242}}, hostname: "renamed-node",
}, },
} }
@@ -4365,15 +4373,19 @@ func TestHandleNodeFromAuthPath_ReloginWholeNode(t *testing.T) {
app.state.PutNodeInStoreForTest(*node) app.state.PutNodeInStoreForTest(*node)
hostname := node.Hostname
if tt.hostname != "" {
hostname = tt.hostname
}
newNodeKey := key.NewNode() newNodeKey := key.NewNode()
authID := types.MustAuthID() authID := types.MustAuthID()
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{ app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
MachineKey: node.MachineKey, MachineKey: node.MachineKey,
NodeKey: newNodeKey.Public(), NodeKey: newNodeKey.Public(),
Hostname: node.Hostname, Hostname: hostname,
Hostinfo: &tailcfg.Hostinfo{ Hostinfo: &tailcfg.Hostinfo{
Hostname: node.Hostname, Hostname: hostname,
Services: tt.services,
}, },
})) }))
@@ -4598,3 +4610,32 @@ func TestRegisterRejectsUnencodableExpiry(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.True(t, latest.Equal(*stored.Expiry)) assert.True(t, latest.Equal(*stored.Expiry))
} }
// TestPendingRegistrationKeepsAuthMetadata pins that operational reports do
// not stay in the auth cache before the first MapRequest.
func TestPendingRegistrationKeepsAuthMetadata(t *testing.T) {
app := createTestApp(t)
hostinfo := &tailcfg.Hostinfo{
Hostname: "pending-router", OS: "linux", OSVersion: "6.12.0",
RequestTags: []string{"tag:router"},
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
Services: []tailcfg.Service{{Proto: tailcfg.PeerAPI4, Port: 41641}},
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
SSH_HostKeys: []string{"ssh-ed25519 key"},
}
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)
entry, ok := app.state.GetAuthCacheEntry(authID)
require.True(t, ok)
assert.Equal(t, &tailcfg.Hostinfo{
Hostname: "pending-router", OS: "linux", RequestTags: []string{"tag:router"},
}, entry.RegistrationData().Hostinfo)
hostinfo.RequestTags[0] = "tag:changed"
assert.Equal(t, []string{"tag:router"}, entry.RegistrationData().Hostinfo.RequestTags)
assert.Len(t, hostinfo.RoutableIPs, 1, "the request itself must be left alone")
}
+36
View File
@@ -1936,6 +1936,42 @@ func TestPollNetMapHandler_UnknownNodeBeforeReportValidation(t *testing.T) {
func TestRegistrationHandler_ValidatesClientReport(t *testing.T) { func TestRegistrationHandler_ValidatesClientReport(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("pending registration retains auth metadata", func(t *testing.T) {
t.Parallel()
app := createTestApp(t)
ns := &noiseServer{headscale: app, machineKey: key.NewMachine().Public()}
rec := serveRegister(t, ns, tailcfg.RegisterRequest{
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: key.NewNode().Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "report-pending-node",
OS: "linux",
OSVersion: "6.12",
RequestTags: []string{"tag:test"},
Services: manyServices(300),
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
},
})
require.Equal(t, http.StatusOK, rec.Code, "body=%q", rec.Body.String())
var resp tailcfg.RegisterResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
authID, err := extractRegistrationIDFromAuthURL(resp.AuthURL)
require.NoError(t, err, "resp=%+v", resp)
entry, ok := app.state.GetAuthCacheEntry(authID)
require.True(t, ok)
assert.Equal(t, &tailcfg.Hostinfo{
Hostname: "report-pending-node",
OS: "linux",
RequestTags: []string{"tag:test"},
}, entry.RegistrationData().Hostinfo)
})
t.Run("oversized report", func(t *testing.T) { t.Run("oversized report", func(t *testing.T) {
t.Parallel() t.Parallel()
+5
View File
@@ -995,6 +995,11 @@ func (a *AuthProviderOIDC) handleRegistration(
if err != nil { if err != nil {
a.h.Change(nodeChange) a.h.Change(nodeChange)
if errors.Is(err, types.ErrHostinfoTooLarge) {
return false, newHTTPUserError(http.StatusBadRequest, "node report too large",
"This device reports too much data. Ask your administrator to reduce its reported metadata, then sign in again.", err)
}
return false, fmt.Errorf("registering node: %w", err) return false, fmt.Errorf("registering node: %w", err)
} }
+43
View File
@@ -2,12 +2,14 @@ package hscontrol
import ( import (
"context" "context"
"encoding/json"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"regexp" "regexp"
"strings"
"testing" "testing"
"time" "time"
@@ -16,6 +18,7 @@ import (
"github.com/oauth2-proxy/mockoidc" "github.com/oauth2-proxy/mockoidc"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/key" "tailscale.com/types/key"
) )
@@ -617,3 +620,43 @@ func TestRouterMethodNotAllowedIncludesAllow(t *testing.T) {
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
assert.Equal(t, []string{http.MethodGet}, resp.Header.Values("Allow")) assert.Equal(t, []string{http.MethodGet}, resp.Header.Values("Allow"))
} }
func TestOIDCRegistrationRejectsOversizedMergedHostinfo(t *testing.T) {
t.Parallel()
app := createTestApp(t)
user := app.state.CreateUserForTest("oidc-report-user")
node := app.state.CreateNodeForTest(user, "oidc-report-node")
node.Hostinfo = &tailcfg.Hostinfo{Hostname: node.Hostname, OSVersion: "v"}
encoded, err := json.Marshal(node.Hostinfo)
require.NoError(t, err)
node.Hostinfo.OSVersion = strings.Repeat("v", types.MaxHostinfoSize-len(encoded))
require.NoError(t, types.ValidateHostinfo(node.Hostinfo))
before := app.state.PutNodeInStoreForTest(*node)
hostname := strings.Repeat("h", types.MaxHostnameLength)
authID := types.MustAuthID()
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
MachineKey: node.MachineKey, NodeKey: key.NewNode().Public(),
Hostname: hostname, Hostinfo: &tailcfg.Hostinfo{Hostname: hostname},
}))
provider := &AuthProviderOIDC{h: app}
changed, err := provider.handleRegistration(user, authID, nil)
require.ErrorIs(t, err, types.ErrHostinfoTooLarge)
var httpErr HTTPError
require.ErrorAs(t, err, &httpErr)
assert.Equal(t, http.StatusBadRequest, httpErr.Code)
assert.Contains(t, strings.ToLower(httpErr.UserMsg), "reduce")
assert.Contains(t, httpErr.UserMsg, "metadata")
assert.Contains(t, httpErr.UserMsg, "sign")
assert.False(t, changed)
after, ok := app.state.GetNodeByID(node.ID)
require.True(t, ok)
assert.Equal(t, before.NodeKey(), after.NodeKey())
assert.Equal(t, before.Hostname(), after.Hostname())
assert.True(t, before.Hostinfo().AsStruct().Equal(after.Hostinfo().AsStruct()))
}
+2 -3
View File
@@ -324,9 +324,8 @@ func TestReloginOfExpiredNodeClearsPeerExpiry(t *testing.T) {
} }
// TestReloginPeerVisibleHostinfoReachesPeers covers the rest of the #3531 // TestReloginPeerVisibleHostinfoReachesPeers covers the rest of the #3531
// class: a relogin stores the RegisterRequest's Hostinfo, so the following // class: peers must see changed services after the first MapRequest following
// MapRequest carries no Hostinfo delta, and a peer-visible change made at // relogin. A key-only patch cannot carry that change.
// relogin reaches peers only if the relogin itself sends the whole node.
func TestReloginPeerVisibleHostinfoReachesPeers(t *testing.T) { func TestReloginPeerVisibleHostinfoReachesPeers(t *testing.T) {
t.Parallel() t.Parallel()
+176 -4
View File
@@ -1,6 +1,7 @@
package state package state
import ( import (
"encoding/json"
"errors" "errors"
"net/netip" "net/netip"
"slices" "slices"
@@ -363,7 +364,6 @@ func TestReauthRejectsNodeKeyClaimedByAnotherMachine(t *testing.T) {
Hostname: "attacker", Hostname: "attacker",
Hostinfo: &tailcfg.Hostinfo{}, Hostinfo: &tailcfg.Hostinfo{},
}, },
ValidHostinfo: &tailcfg.Hostinfo{},
Hostname: "attacker", Hostname: "attacker",
User: attacker, User: attacker,
RegisterMethod: util.RegisterMethodCLI, RegisterMethod: util.RegisterMethodCLI,
@@ -424,7 +424,6 @@ func TestReauthPreservesEndpointsWhenClientOmitsThem(t *testing.T) {
Hostinfo: &tailcfg.Hostinfo{}, Hostinfo: &tailcfg.Hostinfo{},
Endpoints: nil, Endpoints: nil,
}, },
ValidHostinfo: &tailcfg.Hostinfo{},
Hostname: "node", Hostname: "node",
User: user, User: user,
RegisterMethod: util.RegisterMethodCLI, RegisterMethod: util.RegisterMethodCLI,
@@ -1484,7 +1483,7 @@ func TestNewStateDiscardsOversizedHostinfo(t *testing.T) {
"oversized Hostinfo is dropped until the node reports again") "oversized Hostinfo is dropped until the node reports again")
} }
// TestBootKeepsHostinfoTheMapPathStored pins that the startup bound keeps // TestBootKeepsHostinfoTheMapPathStored pins that the startup check keeps
// what the map path accepted. A report without NetInfo keeps the stored one, // what the map path accepted. A report without NetInfo keeps the stored one,
// so a stored Hostinfo can combine fields from two reports. // so a stored Hostinfo can combine fields from two reports.
func TestBootKeepsHostinfoTheMapPathStored(t *testing.T) { func TestBootKeepsHostinfoTheMapPathStored(t *testing.T) {
@@ -1517,10 +1516,183 @@ func TestBootKeepsHostinfoTheMapPathStored(t *testing.T) {
reloaded, ok := persistTestReopen(t, dbPath).GetNodeByID(nodeID) reloaded, ok := persistTestReopen(t, dbPath).GetNodeByID(nodeID)
require.True(t, ok) require.True(t, ok)
require.True(t, reloaded.Hostinfo().Valid(), require.True(t, reloaded.Hostinfo().Valid(),
"the startup bound must keep a Hostinfo the map path stored") "the startup check must keep a Hostinfo the map path stored")
assert.Equal(t, tailcfg.DERPRegionID(1), assert.Equal(t, tailcfg.DERPRegionID(1),
reloaded.Hostinfo().NetInfo().PreferredDERP()) reloaded.Hostinfo().NetInfo().PreferredDERP())
osVersionLen := len(reloaded.Hostinfo().OSVersion()) osVersionLen := len(reloaded.Hostinfo().OSVersion())
assert.Equal(t, 60<<10, osVersionLen) assert.Equal(t, 60<<10, osVersionLen)
} }
// TestReauthKeepsOperationalMetadata covers a relogin before the next map request.
// Pending registrations carry only auth metadata; live connectivity must survive.
func TestReauthKeepsOperationalMetadata(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},
OS: "linux", OSVersion: "6.12.0",
Services: []tailcfg.Service{{Proto: tailcfg.PeerAPI4, Port: 41641}},
SSH_HostKeys: []string{"ssh-ed25519 key"},
NetInfo: &tailcfg.NetInfo{PreferredDERP: 1},
}
n.DiscoKey = key.NewDisco().Public()
n.Endpoints = []netip.AddrPort{netip.MustParseAddrPort("192.0.2.1:41641")}
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))
assert.True(t, node.Hostinfo().Equal(relogged.Hostinfo()))
assert.Equal(t, node.DiscoKey(), relogged.DiscoKey())
assert.Equal(t, node.Endpoints(), relogged.Endpoints())
}
func TestReauthRejectsOversizedMergedHostinfo(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
node, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
hi := &tailcfg.Hostinfo{Hostname: node.Hostname(), OSVersion: "v"}
encoded, err := json.Marshal(hi)
require.NoError(t, err)
hi.OSVersion = strings.Repeat("v", types.MaxHostinfoSize-len(encoded)+1)
require.NoError(t, types.ValidateHostinfo(hi))
_, err = s.UpdateNodeFromMapRequest(nodeID, tailcfg.MapRequest{
NodeKey: node.NodeKey(), DiscoKey: node.DiscoKey(), Hostinfo: hi,
})
require.NoError(t, err)
before, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
rowBefore, err := s.DB().GetNodeByID(nodeID)
require.NoError(t, err)
hostname := strings.Repeat("h", types.MaxHostnameLength)
require.NoError(t, types.ValidateHostinfo(&tailcfg.Hostinfo{Hostname: hostname}))
authID := types.MustAuthID()
s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
MachineKey: node.MachineKey(), NodeKey: key.NewNode().Public(),
Hostname: hostname, Hostinfo: &tailcfg.Hostinfo{Hostname: hostname},
}))
_, _, err = s.HandleNodeFromAuthPath(authID, node.TypedUserID(), nil, util.RegisterMethodOIDC)
require.ErrorIs(t, err, types.ErrHostinfoTooLarge)
after, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
assert.Equal(t, before.AsStruct(), after.AsStruct(), "rejected merge leaves the live node unchanged")
rowAfter, err := s.DB().GetNodeByID(nodeID)
require.NoError(t, err)
assert.Equal(t, rowBefore, rowAfter, "rejected merge leaves the stored node unchanged")
}
func TestReauthKeepsTaggedFutureExpiry(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
_, err := s.SetPolicy([]byte(`{"tagOwners":{"tag:ci":["persist-user@"]}}`))
require.NoError(t, err)
node, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
userID := node.TypedUserID()
_, _, err = s.SetNodeTags(nodeID, []string{"tag:ci"})
require.NoError(t, err)
future := time.Now().Add(time.Hour).UTC()
node, ok = s.nodeStore.UpdateNode(nodeID, func(n *types.Node) {
n.Expiry = &future
})
require.True(t, ok)
require.True(t, node.IsTagged())
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(), RequestTags: []string{"tag:ci"}},
}))
relogged, _, err := s.HandleNodeFromAuthPath(authID, userID, nil, util.RegisterMethodOIDC)
require.NoError(t, err)
require.Equal(t, nodeID, relogged.ID())
require.True(t, relogged.IsTagged())
require.True(t, relogged.Expiry().Valid())
assert.True(t, future.Equal(relogged.Expiry().Get()))
row, err := s.DB().GetNodeByID(nodeID)
require.NoError(t, err)
require.NotNil(t, row.Expiry)
assert.True(t, future.Equal(*row.Expiry))
}
func TestReauthReplacesReportedTags(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
_, err := s.SetPolicy([]byte(`{"tagOwners":{"tag:a":["persist-user@"],"tag:b":["persist-user@"]}}`))
require.NoError(t, err)
node, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
userID := node.TypedUserID()
for _, tags := range [][]string{{"tag:a"}, nil, {"tag:b"}} {
want := slices.Clone(tags)
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(), RequestTags: tags},
}))
relogged, _, err := s.HandleNodeFromAuthPath(authID, userID, nil, util.RegisterMethodOIDC)
require.NoError(t, err)
require.Equal(t, nodeID, relogged.ID())
assert.Equal(t, want, relogged.Hostinfo().RequestTags().AsSlice())
assert.Equal(t, len(want) > 0, relogged.IsTagged())
if len(tags) > 0 {
tags[0] = "tag:changed"
}
stored, ok := s.GetNodeByID(nodeID)
require.True(t, ok)
assert.Equal(t, want, stored.Hostinfo().RequestTags().AsSlice(),
"cached tags must not alias stored Hostinfo")
row, err := s.DB().GetNodeByID(nodeID)
require.NoError(t, err)
assert.Equal(t, want, row.Hostinfo.RequestTags)
}
}
+30 -46
View File
@@ -51,10 +51,8 @@ const (
registerCacheExpiration = time.Minute * 15 registerCacheExpiration = time.Minute * 15
// defaultRegisterCacheMaxEntries is the default upper bound on the number // defaultRegisterCacheMaxEntries is the default upper bound on the number
// of pending registration entries the auth cache will hold. With a 15-minute // of pending registration entries the auth cache will hold. Entries keep
// TTL and a stripped-down RegistrationData payload (~200 bytes per entry), // only hostname, OS and requested tags, capped by [types.ValidateHostinfo].
// 1024 entries cap the worst-case cache footprint at well under 1 MiB even
// under sustained unauthenticated cache-fill attempts.
defaultRegisterCacheMaxEntries = 1024 defaultRegisterCacheMaxEntries = 1024
// defaultNodeStoreBatchSize is the default number of write operations to batch // defaultNodeStoreBatchSize is the default number of write operations to batch
@@ -1973,8 +1971,6 @@ type authNodeUpdateParams struct {
ExistingNode types.NodeView ExistingNode types.NodeView
// Cached registration payload from the originating client request. // Cached registration payload from the originating client request.
RegData *types.RegistrationData RegData *types.RegistrationData
// Pre-validated hostinfo; NetInfo preserved from ExistingNode.
ValidHostinfo *tailcfg.Hostinfo
// Hostname from hostinfo, or generated from keys if client omits it. // Hostname from hostinfo, or generated from keys if client omits it.
Hostname string Hostname string
// Auth user; may differ from ExistingNode.User() on conversion. // Auth user; may differ from ExistingNode.User() on conversion.
@@ -2051,19 +2047,27 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
return types.NodeView{}, false, ErrNodeKeyInUse return types.NodeView{}, false, ErrNodeKeyInUse
} }
// Update existing node in [NodeStore] - validation passed, safe to mutate var hostinfoErr error
before, updatedNodeView, ok := s.nodeStore.UpdateNodeDiff(params.ExistingNode.ID(), func(node *types.Node) {
node.NodeKey = regData.NodeKey
node.DiscoKey = regData.DiscoKey
node.Hostname = params.Hostname
// Preserve NetInfo from existing node when re-registering // Read current operational metadata while holding the NodeStore writer lock.
node.Hostinfo = params.ValidHostinfo before, updatedNodeView, ok := s.nodeStore.UpdateNodeDiff(params.ExistingNode.ID(), func(node *types.Node) {
node.Hostinfo.NetInfo = preserveNetInfo( hostinfo := cmp.Or(node.Hostinfo.Clone(), &tailcfg.Hostinfo{})
params.ExistingNode,
params.ExistingNode.ID(), hostinfo.Hostname = params.Hostname
params.ValidHostinfo, hostinfo.RequestTags = slices.Clone(requestTags)
)
hostinfoErr = types.ValidateHostinfo(hostinfo)
if hostinfoErr != nil {
return
}
node.NodeKey = regData.NodeKey
if !regData.DiscoKey.IsZero() {
node.DiscoKey = regData.DiscoKey
}
node.Hostname = params.Hostname
node.Hostinfo = hostinfo
// Preserve the node's live endpoints when the register request carried // Preserve the node's live endpoints when the register request carried
// none. Web/OIDC relogins report endpoints via MapRequest, not register, // none. Web/OIDC relogins report endpoints via MapRequest, not register,
@@ -2097,34 +2101,11 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
isTagged := node.IsTagged() isTagged := node.IsTagged()
switch { switch {
case wasTagged && !isTagged:
// Tagged → Personal: set expiry from client request
if params.Expiry != nil {
node.Expiry = params.Expiry
} else {
node.Expiry = regData.Expiry
}
case !wasTagged && isTagged:
// Personal → Tagged: clear expiry; tagged nodes disable it by default.
node.Expiry = nil
case params.IsConvertFromTag && !isTagged:
// Explicit conversion from tagged to user-owned: set expiry from client request
if params.Expiry != nil {
node.Expiry = params.Expiry
} else {
node.Expiry = regData.Expiry
}
case !isTagged: case !isTagged:
// Personal → Personal: update expiry from client // User-owned nodes take the expiry from this authentication.
if params.Expiry != nil { node.Expiry = cmp.Or(params.Expiry, regData.Expiry)
node.Expiry = params.Expiry case !wasTagged || node.IsExpired():
} else { // Becoming tagged disables expiry; reauth clears a past expiry.
node.Expiry = regData.Expiry
}
case isTagged && node.IsExpired():
// Tagged → Tagged and expired: a person re-authenticated it, so clear
// the past expiry. A future administrative expiry has not taken
// effect and falls through to the no-op below.
node.Expiry = nil node.Expiry = nil
} }
// Tagged → Tagged and not expired: keep existing expiry - no action. // Tagged → Tagged and not expired: keep existing expiry - no action.
@@ -2145,6 +2126,10 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
return types.NodeView{}, false, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, params.ExistingNode.ID()) return types.NodeView{}, false, fmt.Errorf("%w: %d", ErrNodeNotInNodeStore, params.ExistingNode.ID())
} }
if hostinfoErr != nil {
return types.NodeView{}, false, hostinfoErr
}
keysOnly := onlyKeysChanged(before, updatedNodeView, time.Now()) keysOnly := onlyKeysChanged(before, updatedNodeView, time.Now())
// Persist to database. // Persist to database.
@@ -2585,7 +2570,6 @@ func (s *State) HandleNodeFromAuthPath(
// Common params for update operations // Common params for update operations
updateParams := authNodeUpdateParams{ updateParams := authNodeUpdateParams{
RegData: regData, RegData: regData,
ValidHostinfo: hostinfo,
Hostname: hostname, Hostname: hostname,
User: user, User: user,
Expiry: expiry, Expiry: expiry,
+6 -13
View File
@@ -13,11 +13,9 @@ import (
// only the fields the registration callback path actually consumes when // only the fields the registration callback path actually consumes when
// promoting a pending registration to a real node. // promoting a pending registration to a real node.
// //
// Combined with the bounded-LRU cache that holds these entries, this caps // Any client can open a pending registration. The bounded-LRU cache that
// the worst-case memory footprint of unauthenticated cache-fill attempts // holds these entries caps their number. Hostinfo retains only auth and
// at (max_entries × per_entry_size). The cache is sized so that the // confirmation metadata, bounded by [ValidateHostinfo] before caching.
// product is bounded to a few MiB even with attacker-supplied 1 MiB
// Hostinfos (the Noise body limit).
type RegistrationData struct { type RegistrationData struct {
// MachineKey is the cryptographic identity of the machine being // MachineKey is the cryptographic identity of the machine being
// registered. Required. // registered. Required.
@@ -34,14 +32,9 @@ type RegistrationData struct {
// Already validated/normalised by EnsureHostname at producer time. // Already validated/normalised by EnsureHostname at producer time.
Hostname string Hostname string
// Hostinfo is the original [tailcfg.Hostinfo] from the [tailcfg.RegisterRequest], // Hostinfo carries hostname and requested tags for auth, and OS for the
// stored so that the auth callback can populate the new node's // confirmation page. Other fields arrive in the first MapRequest.
// initial [tailcfg.Hostinfo] (and so that observability/CLI consumers see // It may be nil when the registration did not include Hostinfo.
// fields like OS, OSVersion, and IPNVersion before the first
// [tailcfg.MapRequest] restores the live set).
//
// May be nil if the client did not send [tailcfg.Hostinfo] in the original
// [tailcfg.RegisterRequest].
Hostinfo *tailcfg.Hostinfo Hostinfo *tailcfg.Hostinfo
// Endpoints is the initial set of WireGuard endpoints the node // Endpoints is the initial set of WireGuard endpoints the node