ipn/ipnlocal: reject SrcCaps-based packet filter rules for unsigned peers (#20513)

This change ensures `packetFilterPermitsUnlockedNodes` also considers SrcCaps-based grants when checking for unsigned peer access.

Fixes tailscale/corp#45116

Change-Id: I0ac938367888f67ed6f355fc19959cc8c31722a2

Signed-off-by: Mike Jensen <mikej@tailscale.com>
This commit is contained in:
Mike Jensen
2026-07-17 15:58:06 -06:00
committed by GitHub
parent bab3f5fce7
commit 689c6c2e6d
2 changed files with 71 additions and 1 deletions
+1 -1
View File
@@ -9136,7 +9136,7 @@ func (b *LocalBackend) srcIPHasCapForFilter(srcIP netip.Addr, cap tailcfg.NodeCa
if !ok {
return false
}
return n.HasCap(cap)
return !n.UnsignedPeerAPIOnly() && n.HasCap(cap)
}
// maybeUsernameOf returns the actor's username if the actor
+70
View File
@@ -8037,6 +8037,76 @@ func TestSrcCapPacketFilter(t *testing.T) {
}
}
func TestSrcCapPacketFilterUnsignedPeer(t *testing.T) {
lb := newLocalBackendWithTestControl(t, false, func(tb testing.TB, opts controlclient.Options) controlclient.Client {
return newClient(tb, opts)
})
if err := lb.Start(ipn.Options{}); err != nil {
t.Fatalf("(*LocalBackend).Start(): %v", err)
}
var signedKey, unsignedKey key.NodePublic
must.Do(signedKey.UnmarshalText([]byte("nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261")))
must.Do(unsignedKey.UnmarshalText([]byte("nodekey:6c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf262")))
controlClient := lb.cc.(*mockControl)
controlClient.send(sendOpt{nm: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Addresses: []netip.Prefix{netip.MustParsePrefix("1.1.1.1/32")},
}).View(),
Peers: []tailcfg.NodeView{
// A normal (signed) peer holding cap-X: it should be accepted.
(&tailcfg.Node{
Addresses: []netip.Prefix{netip.MustParsePrefix("2.2.2.2/32")},
ID: 2,
Key: signedKey,
CapMap: tailcfg.NodeCapMap{"cap-X": nil},
}).View(),
// An unsigned peer that control has also granted cap-X: it must be
// dropped despite holding the capability, because tailnet lock does
// not trust it.
(&tailcfg.Node{
Addresses: []netip.Prefix{netip.MustParsePrefix("3.3.3.3/32")},
ID: 3,
Key: unsignedKey,
UnsignedPeerAPIOnly: true,
CapMap: tailcfg.NodeCapMap{"cap-X": nil},
}).View(),
},
PacketFilter: []filtertype.Match{{
IPProto: views.SliceOf([]ipproto.Proto{ipproto.TCP}),
SrcCaps: []tailcfg.NodeCapability{"cap-X"},
Dsts: []filtertype.NetPortRange{{
Net: netip.MustParsePrefix("1.1.1.1/32"),
Ports: filtertype.PortRange{
First: 22,
Last: 22,
},
}},
}},
}})
f := lb.ForTest().GetFilter()
// The signed peer with the capability is accepted
if res := f.Check(netip.MustParseAddr("2.2.2.2"), netip.MustParseAddr("1.1.1.1"), 22, ipproto.TCP); res != filter.Accept {
t.Errorf("Check(signed 2.2.2.2, ...) = %s, want %s", res, filter.Accept)
}
// The unsigned peer with the same capability is dropped
if res := f.Check(netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("1.1.1.1"), 22, ipproto.TCP); !res.IsDrop() {
t.Errorf("Check(unsigned 3.3.3.3, ...) = %s, want drop", res)
}
// Directly exercise the runtime capability test used by the filter
if lb.srcIPHasCapForFilter(netip.MustParseAddr("3.3.3.3"), "cap-X") {
t.Error("srcIPHasCapForFilter returned true for UnsignedPeerAPIOnly peer")
}
if !lb.srcIPHasCapForFilter(netip.MustParseAddr("2.2.2.2"), "cap-X") {
t.Error("srcIPHasCapForFilter returned false for signed peer with cap")
}
}
func TestDisplayMessages(t *testing.T) {
b := newTestLocalBackend(t)