policy/v2: match default proto set for tests with no proto

The policy `tests` block lets entries omit `proto`. Tailscale's client
maps that to the default protocol set {TCP, UDP, ICMP, ICMPv6} — the
captured packet_filter_matches show all four IANA numbers explicitly
when no proto is set — and a rule restricted to any one of them
satisfies an empty-proto reachability test.

srcReachesDst was passing the empty Protocol through unchanged, which
landed an empty []int in ruleMatchesProto. The matcher then short-
circuited to "no match" for every rule with a non-empty IPProto
restriction, including TCP-only grants compiled from `ip: ["tcp:80"]`.
The bug surfaced in the captured allpass-acls-and-grants-mixed
scenario: the grant `tag:client → webserver:80` was reachable in the
compiled filter but the empty-proto test could not see it.

Expand the empty Protocol to the default set at the call site so
ruleMatchesProto's intersection check sees the right requested
protocols. Drop the now-dead empty-requestedProtos branch from the
matcher. The last divergence drops out of knownPolicyTesterDivergences
as a result.

Updates #1803
This commit is contained in:
Kristoffer Dalby 2026-04-29 12:26:20 +00:00
parent e4e209f919
commit d5b2837231
3 changed files with 69 additions and 13 deletions

View file

@ -314,8 +314,17 @@ func parseDestinationAlias(dst string) (*AliasWithPorts, error) {
// srcReachesDst walks the compiled filter rules and reports whether
// traffic from src to any prefix in dstPrefixes on at least one of ports
// (or any port when ports is empty) is allowed under proto.
//
// An empty test proto means the Tailscale client default set
// {TCP, UDP, ICMP, ICMPv6} — the protocols the client tries when proto
// is omitted. The captured Tailscale matches show these four IANA
// numbers explicitly when no proto is set, so a rule restricted to any
// of them satisfies an empty-proto test.
func srcReachesDst(src netip.Prefix, dstPrefixes []netip.Prefix, ports []tailcfg.PortRange, proto Protocol, filter []tailcfg.FilterRule) bool {
requestedProtos := proto.toIANAProtocolNumbers()
if len(requestedProtos) == 0 {
requestedProtos = []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}
}
for _, rule := range filter {
if !ruleMatchesSource(rule, src) {
@ -354,21 +363,15 @@ func ruleMatchesSource(rule tailcfg.FilterRule, src netip.Prefix) bool {
return false
}
// ruleMatchesProto reports whether the rule permits the requested
// protocols. An unset rule.IPProto means "any protocol" and matches
// everything; an empty requestedProtos (proto == "") means the default
// set, which matches any rule including unset ones.
// ruleMatchesProto reports whether the rule permits any of requestedProtos.
// An unset rule.IPProto means "any protocol" and matches everything.
// requestedProtos is the per-test protocol set: a single proto for an
// explicit test.Proto, or the default set when test.Proto is empty.
func ruleMatchesProto(rule tailcfg.FilterRule, requestedProtos []int) bool {
if len(rule.IPProto) == 0 {
return true
}
if len(requestedProtos) == 0 {
// Default set: a rule restricted to a non-default protocol does
// not match the default request.
return false
}
for _, ruleProto := range rule.IPProto {
if slices.Contains(requestedProtos, ruleProto) {
return true