mapper, state: deliver nodeAttrs through MapResponse and harden nextdns DoH rewrite

WithSelfNode and buildTailPeers merge each node's policy CapMap
into the tailcfg.Node.CapMap they emit. State.NodeCapMap and
State.NodeCapMaps wrap the policy manager: NodeCapMap returns a
defensive clone per call; NodeCapMaps snapshots the full per-node
map once for batched callers, amortising pm.mu acquisition across
a peer build.

generateDNSConfig grew a per-node CapMap argument so it can apply
nodeAttr-driven DNS overlays. The nextdns DoH rewrite hardens against
policy-controlled inputs:

  - nextDNSDoHHost anchors the prefix match instead of substring,
    so a hostile resolver URL cannot smuggle a nextdns hostname in
    a path or query.
  - nextDNSProfileFromCapMap accepts only profile names matching
    [A-Za-z0-9._-]{1,64} and picks the lexicographically first when
    multiple are granted -- deterministic, no shell metacharacters
    or URL fragments through.
  - addNextDNSMetadata composes the rewritten URL via url.Parse +
    url.Values rather than fmt.Sprintf, so existing query strings
    on the resolver URL survive and metadata cannot inject a new
    component.

WithTaildropEnabled in servertest controls cfg.Taildrop.Enabled per
test so cap/file-sharing emission can be toggled in tests that need
to verify the off path.
This commit is contained in:
Kristoffer Dalby 2026-05-11 14:47:58 +00:00
parent a4f05b0962
commit 6fcff9e352
5 changed files with 312 additions and 19 deletions

View file

@ -1,6 +1,7 @@
package mapper
import (
"maps"
"net/netip"
"slices"
"sort"
@ -90,6 +91,14 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
return b
}
if policyCaps := b.mapper.state.NodeCapMap(nv.ID()); len(policyCaps) > 0 {
if tailnode.CapMap == nil {
tailnode.CapMap = make(tailcfg.NodeCapMap, len(policyCaps))
}
maps.Copy(tailnode.CapMap, policyCaps)
}
b.resp.Node = tailnode
return b
@ -158,7 +167,7 @@ func (b *MapResponseBuilder) WithDNSConfig() *MapResponseBuilder {
return b
}
b.resp.DNSConfig = generateDNSConfig(b.mapper.cfg, node)
b.resp.DNSConfig = generateDNSConfig(b.mapper.cfg, node, b.mapper.state.NodeCapMap(node.ID()))
return b
}
@ -266,6 +275,22 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
return nil, err
}
// Each peer's CapMap travels alongside the peer entry --
// Tailscale's client reads it for `NodeAttrSuggestExitNode`,
// `NodeAttrDNSSubdomainResolve`, and other peer-self attrs
// (see tstest/integration/testcontrol/testcontrol.go:1350,
// ipn/ipnlocal/local.go:7562, ipn/ipnlocal/node_backend.go:745).
// TailNode already stamped the baseline; merge the
// peer's own policy nodeAttrs delta on top so peer-side
// consumers see the same value the peer sees on its self entry.
if policyCaps := b.mapper.state.NodeCapMap(peer.ID()); len(policyCaps) > 0 {
if tn.CapMap == nil {
tn.CapMap = make(tailcfg.NodeCapMap, len(policyCaps))
}
maps.Copy(tn.CapMap, policyCaps)
}
tailPeers = append(tailPeers, tn)
}