mapper: take peer visibility from the peer map only

Fixes #3408
This commit is contained in:
Kristoffer Dalby
2026-09-10 13:55:09 +00:00
parent 5861005ef6
commit e48bc46cc6
10 changed files with 134 additions and 962 deletions
+11 -67
View File
@@ -14,7 +14,6 @@ import (
"strings"
"time"
"github.com/juanfont/headscale/hscontrol/policy"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
@@ -412,7 +411,7 @@ func (m *mapper) buildFromChange(
} else {
if len(resp.PeersChanged) > 0 {
peers := m.state.ListPeers(nodeID, resp.PeersChanged...)
builder.WithUserProfiles(m.filterVisibleNodes(nodeID, peers))
builder.WithUserProfiles(peers)
builder.WithPeerChanges(peers)
}
@@ -433,48 +432,9 @@ func (m *mapper) buildFromChange(
return builder.Build()
}
// visiblePeerIDs returns the set of peer node IDs the recipient may see under
// the current policy. It is the single visibility decision shared by the
// incremental peer-change and user-profile paths, computed from the same live
// per-node matchers and [policy.ReduceNodes] filter that
// [MapResponseBuilder.buildTailPeers] applies to full peer objects, so the
// paths cannot drift. The recipient's adjacency from the NodeStore
// ([NodeStore.ListPeers]) is the authority for which peers exist for it; the
// live matchers only narrow that set further, matching buildTailPeers.
//
// ok is false when the node or its matchers cannot be resolved; callers must
// then fail closed (emit nothing) rather than risk leaking forbidden peers.
func (m *mapper) visiblePeerIDs(nodeID types.NodeID) (map[tailcfg.NodeID]struct{}, bool) {
node, ok := m.state.GetNodeByID(nodeID)
if !ok {
return nil, false
}
matchers, err := m.state.MatchersForNode(node)
if err != nil {
return nil, false
}
peers := m.state.ListPeers(nodeID)
// No matchers means no policy restrictions, so every peer is visible —
// the same default buildTailPeers applies.
if len(matchers) > 0 {
peers = policy.ReduceNodes(node, peers, matchers)
}
// Key by tailcfg.NodeID so the peer-patch path can look up by patch.NodeID
// directly, avoiding an unchecked int64->uint64 conversion.
visible := make(map[tailcfg.NodeID]struct{}, peers.Len())
for _, peer := range peers.All() {
visible[peer.ID().NodeID()] = struct{}{}
}
return visible, true
}
// filterVisiblePeerPatches drops peer-change patches whose target peer the
// recipient cannot see under the ACL policy. Without it, online/offline,
// filterVisiblePeerPatches drops peer-change patches whose target is not in
// the recipient's NodeStore peer map, the same set
// [MapResponseBuilder.buildTailPeers] is fed from. Without it, online/offline,
// endpoint, and key-expiry patches disclose the existence, presence, and
// addresses of peers the recipient's policy forbids it from accessing.
func (m *mapper) filterVisiblePeerPatches(
@@ -485,10 +445,13 @@ func (m *mapper) filterVisiblePeerPatches(
return patches
}
visible, ok := m.visiblePeerIDs(nodeID)
if !ok {
// Fail closed: if visibility cannot be resolved, send no patches.
return nil
// Key by tailcfg.NodeID so patches are looked up by patch.NodeID
// directly, avoiding an unchecked int64->uint64 conversion.
peers := m.state.ListPeers(nodeID)
visible := make(map[tailcfg.NodeID]struct{}, peers.Len())
for _, peer := range peers.All() {
visible[peer.ID().NodeID()] = struct{}{}
}
return filterByVisible(visible, patches, func(p *tailcfg.PeerChange) tailcfg.NodeID {
@@ -496,25 +459,6 @@ func (m *mapper) filterVisiblePeerPatches(
})
}
// filterVisibleNodes restricts a peer slice to the nodes the recipient can see
// under the ACL policy. It guards UserProfiles on the incremental PeersChanged
// path, which receives an unfiltered node slice and would otherwise leak the
// identities of users whose nodes the recipient cannot access.
func (m *mapper) filterVisibleNodes(
nodeID types.NodeID,
peers views.Slice[types.NodeView],
) views.Slice[types.NodeView] {
visible, ok := m.visiblePeerIDs(nodeID)
if !ok {
// Fail closed: emit no peer user profiles rather than risk a leak.
return views.SliceOf([]types.NodeView{})
}
return views.SliceOf(filterByVisible(visible, peers.AsSlice(), func(p types.NodeView) tailcfg.NodeID {
return p.ID().NodeID()
}))
}
// filterByVisible keeps only the items whose key resolves to a NodeID present
// in the visible set, preserving input order.
func filterByVisible[T any](