mirror of
https://github.com/fosrl/newt.git
synced 2026-09-26 07:49:05 +02:00
145 lines
4.6 KiB
Go
145 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|