mirror of
https://github.com/fosrl/newt.git
synced 2026-09-25 23:39:07 +02:00
Don't fall through port restrictions to gateway resources with 0.0.0.0/0
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user