From 689c6c2e6d24763829f9d991a9037b92d8ee9ef4 Mon Sep 17 00:00:00 2001 From: Mike Jensen Date: Fri, 17 Jul 2026 15:58:06 -0600 Subject: [PATCH] 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 --- ipn/ipnlocal/local.go | 2 +- ipn/ipnlocal/local_test.go | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 8a006057a..75e622343 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -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 diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 1751792e1..814ec8c72 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -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)