LowFixed5 min read
Unsigned peers can hold peer capabilities under tailnet lock
Tailnet lock exists to stop a malicious control plane from injecting nodes a tailnet's own keys never signed. But the capability-enforcement path let an unsigned peer keep peer capabilities the packet filter granted it, including SrcCaps-based grants, so a node tailnet lock was supposed to distrust could still act on capabilities it should never hold. Reported to Tailscale; fixed across #20513 and #20561, the completing fix credited to me.
- Vendor
- Tailscale
- Product
- Tailscale (tailnet lock / TKA)
- Weakness
- CWE-863 / CWE-284
- Affected
- Tailnets with tailnet lock (TKA) enabled
- Fixed in
- #20513 and #20561 (both merged)
Disclosure timeline
Jul 2026
Reported to Tailscale that unsigned peers could still hold peer capabilities under tailnet lock. Tracked internally as corp#45116.
Jul 17, 2026
PR #20513 merged: packetFilterPermitsUnlockedNodes now also rejects SrcCaps-based grants for unsigned peers. First pass, left gaps.
Jul 21, 2026
PR #20561 opened to deny peer capabilities to unsigned peers outright, crediting the original report and follow-up feedback.
Jul 22, 2026
PR #20561 merged. Following review feedback, the completing fix strips advertised routes from unsigned peers at netmap ingestion, so the fast-path source check suffices and the fragile slow path is unneeded.
Tailnet lock (TKA, tailnet key authority) is Tailscale's defense against a compromised or malicious coordination server. Normally the control plane is trusted to tell each node who its peers are and what keys they use. Tailnet lock removes that trust: nodes are added to the tailnet only if their keys are signed by keys the tailnet itself holds, and a node the control plane tries to introduce without a valid signature is an unsigned peer, one that the rest of the tailnet is supposed to distrust.
The whole point of that boundary is that an unsigned peer should get nothing that depends on being trusted. This bug let one keep a class of things it should never have held: peer capabilities.
The bug
Peer capabilities are grants attached to a peer in the network map, the packet filter can hand a peer capabilities that gate privileged behavior between nodes. Tailnet lock's job is to make sure an unsigned peer is stripped of anything trust-dependent before the rest of the client acts on the netmap.
The enforcement was incomplete. The check that decides whether the packet filter is granting access to an unlocked (unsigned) node did not fully account for how capabilities can be granted, so an unsigned peer could still end up holding peer capabilities the filter had assigned it. In particular, grants expressed through SrcCaps (source-capability packet-filter rules) were not considered by the unsigned-peer check at all, so that path handed capabilities to a node tailnet lock was meant to distrust.
Why it matters, and why it's Low
The precondition is the important part. To reach this you need to control what the tailnet's nodes are told about their peers, marking a node as an unsigned peer and arranging the packet-filter grants. That is the control plane acting as the adversary, which is precisely the threat tailnet lock is designed to contain, and the highest bar in Tailscale's model. There is no path here for an ordinary peer, or a network attacker, to grant itself capabilities.
The impact ceiling is also bounded by what the capability in question actually gates: an unsigned peer holding a capability it shouldn't is a real trust-boundary violation, but it is not by itself remote code execution or data theft. Tailscale assessed it below their security-bulletin threshold on that basis, so there is no CVE. I think that rating is correct, and I'd rather say so than inflate it. What makes the finding worth writing up is not a dramatic severity number; it's that the enforcement property turned out to be systematically leaky, and closing it correctly took more than one attempt.
The fix, in two passes
#20513 (merged). The first fix extended packetFilterPermitsUnlockedNodes so that it also considers SrcCaps-based grants when checking whether an unsigned peer is being given access. That closed the specific SrcCaps vector.
#20561 (merged). Reviewing the residual, the maintainers concluded the check needed to be broader still: an unsigned peer must never hold peer capabilities at all, regardless of how the packet filter tried to grant them. #20561 extends the unsigned-peer check to cover capabilities generally, rather than patching one grant mechanism at a time. Its description notes it is a follow-up to #20513 "which still left gaps."
A residual caught in review
The interesting part is what code review turned up on #20561. The completing fix attributes a subnet-routed source address back to a peer so it can ask "is this an unsigned peer?" On the slow path it did that with Outbound().LookupPrefixLPM, the routing table's winning route for a prefix.
That table answers "who owns this prefix," not "who could this packet have come from." Under asymmetric or multi-homed routing, several nodes can advertise the same prefix and inbound traffic can arrive from a non-winner. If a signed node wins a prefix that an unsigned node also advertises, a source in that prefix resolves to the signed node, the unsigned-peer check returns false, and the capability is granted anyway. Because control is the adversary here, that asymmetric case isn't a rare accident, it can be constructed deliberately: mark a node as an unsigned peer, give it a broad advertised route, and arrange for a signed node to win that prefix.
The durable fix a reviewer proposed is an invariant rather than a smarter lookup: an unsigned peer should carry no advertised routes in the first place, only its own address. With that enforced at netmap ingestion, the fast per-address lookup is sufficient and the fragile slow path is unnecessary. It also matches intent, an unsigned peer has no business being a subnet router or exit node under tailnet lock regardless. That is what merged: #20561 strips any AllowedIPs beyond an unsigned peer's own addresses during netmap ingestion, and the source check resolves addresses through the direct nodeByAddr lookup rather than the winning-route table. Review turned a one-mechanism patch into the general invariant, and it does not change the severity, only the completeness of the fix.
Takeaway
Trust-boundary enforcement is only as good as its least-covered path. Tailnet lock had the right idea, unsigned peers get nothing trust-dependent, but "nothing" has to include every mechanism by which a capability can reach a peer, and it took SrcCaps coverage (#20513), then a general capability denial (#20561), then a routing-attribution correction in review to get there. The precondition keeps this Low, and it should stay Low. The value is in the depth: each pass showed the property was leakier than it looked.