Don't fall through port restrictions to gateway resources with 0.0.0.0/0

This commit is contained in:
Owen
2026-09-23 15:23:59 -04:00
parent f6a4be0137
commit 7e0f83aeae
2 changed files with 163 additions and 1 deletions
+19 -1
View File
@@ -175,13 +175,27 @@ func (sl *SubnetLookup) Match(srcIP, dstIP netip.Addr, port uint16, proto tcpip.
continue
}
// Supernets() yields longest-prefix-match first, then progressively
// less specific. Once a more specific, non-catch-all destination
// rule has been seen and rejected (wrong port/protocol), a
// 0.0.0.0/0 (or ::/0) exit-node rule must not be allowed to rescue
// it - "whole subnet" routing only applies when no more specific
// resource covers this destination at all. Fallthrough between two
// specific (non-catch-all) rules is intentional and unaffected.
sawRejectedSpecificDest := false
// Step 2: Find all destination prefixes that contain dstIP
// This is also O(log n) for each matching source prefix
for _, rules := range destTriePtr.trie.Supernets(dstPrefix) {
for destPrefix, rules := range destTriePtr.trie.Supernets(dstPrefix) {
if rules == nil {
continue
}
isCatchAll := destPrefix.Bits() == 0
if isCatchAll && sawRejectedSpecificDest {
return nil
}
// Step 3: Check each rule for ICMP and port restrictions
for _, rule := range rules {
// Handle ICMP before port range check — ICMP has no ports
@@ -216,6 +230,10 @@ func (sl *SubnetLookup) Match(srcIP, dstIP netip.Addr, port uint16, proto tcpip.
}
}
}
if !isCatchAll {
sawRejectedSpecificDest = true
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package netstack2
import (
"net/netip"
"testing"
"gvisor.dev/gvisor/pkg/tcpip/header"
)
// clientPrefix is the shared SourcePrefix used across these tests, mirroring
// how the server always assigns a /32 per client (server/lib/ip.ts).
var clientPrefix = netip.MustParsePrefix("10.0.0.5/32")
var clientIP = clientPrefix.Addr()
func TestMatch_SpecificResourceRejectsPort_DoesNotFallThroughToExitNode(t *testing.T) {
sl := NewSubnetLookup()
// A specific /24 resource restricted to port 443 only.
sl.AddSubnet(SubnetRule{
SourcePrefix: clientPrefix,
DestPrefix: netip.MustParsePrefix("192.168.1.0/24"),
PortRanges: []PortRange{{Min: 443, Max: 443, Protocol: "tcp"}},
ResourceId: 100,
})
// A 0.0.0.0/0 exit-node rule with no port restriction.
sl.AddSubnet(SubnetRule{
SourcePrefix: clientPrefix,
DestPrefix: netip.MustParsePrefix("0.0.0.0/0"),
ResourceId: 999,
})
inCIDR := netip.MustParseAddr("192.168.1.50")
// Disallowed port on the in-CIDR destination must be denied outright,
// not rescued by the exit node's permissive catch-all.
if rule := sl.Match(clientIP, inCIDR, 22, header.TCPProtocolNumber); rule != nil {
t.Fatalf("expected deny for disallowed port via specific resource, got rule with ResourceId=%d", rule.ResourceId)
}
// Allowed port on the in-CIDR destination must match the specific resource.
rule := sl.Match(clientIP, inCIDR, 443, header.TCPProtocolNumber)
if rule == nil {
t.Fatal("expected match for allowed port on specific resource, got nil")
}
if rule.ResourceId != 100 {
t.Fatalf("expected ResourceId=100 (specific resource), got %d", rule.ResourceId)
}
// A destination outside the /24 has no specific resource covering it,
// so the exit node must still catch it normally.
outsideCIDR := netip.MustParseAddr("8.8.8.8")
rule = sl.Match(clientIP, outsideCIDR, 22, header.TCPProtocolNumber)
if rule == nil {
t.Fatal("expected exit-node match for destination outside the specific resource, got nil")
}
if rule.ResourceId != 999 {
t.Fatalf("expected ResourceId=999 (exit node), got %d", rule.ResourceId)
}
}
func TestMatch_NonCatchAllFallthroughStillWorks(t *testing.T) {
sl := NewSubnetLookup()
// A very specific /32 limited to SSH only.
sl.AddSubnet(SubnetRule{
SourcePrefix: clientPrefix,
DestPrefix: netip.MustParsePrefix("192.168.1.50/32"),
PortRanges: []PortRange{{Min: 22, Max: 22, Protocol: "tcp"}},
ResourceId: 1,
})
// A broader /24 (non-catch-all) that allows HTTP.
sl.AddSubnet(SubnetRule{
SourcePrefix: clientPrefix,
DestPrefix: netip.MustParsePrefix("192.168.1.0/24"),
PortRanges: []PortRange{{Min: 80, Max: 80, Protocol: "tcp"}},
ResourceId: 2,
})
ip := netip.MustParseAddr("192.168.1.50")
// Port 80 doesn't match the /32's SSH-only rule, so it must still fall
// through to the broader /24 rule that allows it (non-catch-all
// fallthrough is preserved).
rule := sl.Match(clientIP, ip, 80, header.TCPProtocolNumber)
if rule == nil {
t.Fatal("expected fallthrough match on broader /24 rule, got nil")
}
if rule.ResourceId != 2 {
t.Fatalf("expected ResourceId=2 (broader /24 resource), got %d", rule.ResourceId)
}
// Port 22 matches the /32 directly.
rule = sl.Match(clientIP, ip, 22, header.TCPProtocolNumber)
if rule == nil {
t.Fatal("expected match on specific /32 rule, got nil")
}
if rule.ResourceId != 1 {
t.Fatalf("expected ResourceId=1 (specific /32 resource), got %d", rule.ResourceId)
}
}
func TestMatch_ICMPDisabledOnSpecificResource_HardDeniesRegardlessOfExitNode(t *testing.T) {
sl := NewSubnetLookup()
sl.AddSubnet(SubnetRule{
SourcePrefix: clientPrefix,
DestPrefix: netip.MustParsePrefix("192.168.1.0/24"),
DisableIcmp: true,
ResourceId: 100,
})
sl.AddSubnet(SubnetRule{
SourcePrefix: clientPrefix,
DestPrefix: netip.MustParsePrefix("0.0.0.0/0"),
ResourceId: 999,
})
ip := netip.MustParseAddr("192.168.1.50")
if rule := sl.Match(clientIP, ip, 0, header.ICMPv4ProtocolNumber); rule != nil {
t.Fatalf("expected ICMP deny on specific resource, got rule with ResourceId=%d", rule.ResourceId)
}
}
func TestMatch_ExitNodeOnlyMatchesWhenNoSpecificResourceCovers(t *testing.T) {
sl := NewSubnetLookup()
sl.AddSubnet(SubnetRule{
SourcePrefix: clientPrefix,
DestPrefix: netip.MustParsePrefix("0.0.0.0/0"),
ResourceId: 999,
})
ip := netip.MustParseAddr("1.2.3.4")
rule := sl.Match(clientIP, ip, 443, header.TCPProtocolNumber)
if rule == nil {
t.Fatal("expected exit-node match when no specific resource exists, got nil")
}
if rule.ResourceId != 999 {
t.Fatalf("expected ResourceId=999, got %d", rule.ResourceId)
}
}