go-firewall/wf_windows_test.go
2026-07-08 15:54:48 -05:00

296 lines
11 KiB
Go

package firewall
import (
"context"
"testing"
wapi "github.com/iamacarpet/go-win64api"
"github.com/stretchr/testify/require"
)
func TestWFFeatureRules(t *testing.T) {
fw := &WF{rulePrefix: "test"}
// Round-trip the rule shapes the WFP backend supports: ICMP/ICMPv6
// protocols and single/list/range ports.
rules := []*Rule{
{Proto: ICMP, Action: Accept},
{Proto: ICMPv6, Action: Drop},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Drop},
{Proto: TCP, Port: 22, Action: Accept},
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
{Direction: DirOutput, Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
}
for _, r := range rules {
fr, err := fw.MarshallFWRule("", r)
require.NoError(t, err, "failed to marshal %+v", *r)
parsed := fw.UnmarshallFWRule(*fr)
require.NotNil(t, parsed, "failed to parse marshalled rule for %+v", *r)
require.True(t, parsed.Equal(r, true),
"round-trip mismatch: input %+v, output %+v", *r, parsed)
}
// Features the WFP model cannot express are rejected.
unsupported := []*Rule{
{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept},
{InInterface: "Ethernet", Proto: TCP, Port: 22, Action: Accept},
}
for _, r := range unsupported {
_, err := fw.MarshallFWRule("", r)
require.Error(t, err, "expected error marshalling unsupported rule %+v", *r)
}
}
// TestWFFamilyScopeRejected verifies that a rule scoped to a single IP family
// but carrying no family-determining match (no address, non-ICMP protocol) is
// rejected rather than silently applied to both families. WFP has no per-rule
// family selector, so honoring such a Family is impossible; the rule would widen
// to both stacks and read back as FamilyAny, churning on every Sync.
func TestWFFamilyScopeRejected(t *testing.T) {
fw := &WF{rulePrefix: "test"}
// Unrepresentable: an explicit family with nothing to carry it.
for _, r := range []*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Family: IPv6, Proto: TCP, Port: 22, Action: Accept},
{Family: IPv6, Proto: UDP, Action: Accept},
} {
_, err := fw.MarshallFWRule("", r)
require.Error(t, err, "expected a family-scope rejection for %+v", *r)
}
// Representable: FamilyAny (no scope), an address carries the family, or ICMP
// implies it. These round-trip.
for _, r := range []*Rule{
{Proto: TCP, Port: 22, Action: Accept},
{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept},
{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept},
{Family: IPv4, Proto: ICMP, Action: Accept},
} {
fr, err := fw.MarshallFWRule("", r)
require.NoError(t, err, "expected %+v to marshal", *r)
parsed := fw.UnmarshallFWRule(*fr)
require.NotNil(t, parsed)
require.True(t, parsed.Equal(r, true), "round-trip mismatch: input %+v, output %+v", *r, parsed)
}
}
// TestWFProtocolAndComment round-trips the added portless IP protocols (mapped
// to raw protocol numbers) and a rule comment (carried in the filter
// description). A port on a non-tcp/udp protocol is rejected.
func TestWFProtocolAndComment(t *testing.T) {
fw := &WF{rulePrefix: "test"}
rules := []*Rule{
{Proto: GRE, Action: Accept},
{Proto: ESP, Action: Accept},
{Proto: AH, Action: Drop},
{Proto: TCP, Port: 22, Action: Accept, Comment: "ssh access"},
}
for _, r := range rules {
fr, err := fw.MarshallFWRule("", r)
require.NoError(t, err, "failed to marshal %+v", *r)
parsed := fw.UnmarshallFWRule(*fr)
require.NotNil(t, parsed, "failed to parse %+v", *r)
require.True(t, parsed.Equal(r, true), "round-trip mismatch: %+v vs %+v", *r, parsed)
require.Equal(t, r.Comment, parsed.Comment, "comment round-trip for %+v", *r)
}
// SCTP carries ports in the model, but WFP cannot match ports on it.
_, err := fw.MarshallFWRule("", &Rule{Proto: SCTP, Port: 9000, Action: Accept})
require.Error(t, err, "WFP should reject a port on SCTP")
// A portless SCTP rule is fine.
fr, err := fw.MarshallFWRule("", &Rule{Proto: SCTP, Action: Accept})
require.NoError(t, err)
parsed := fw.UnmarshallFWRule(*fr)
require.NotNil(t, parsed)
require.Equal(t, SCTP, parsed.Proto)
// Windows Firewall has only inbound/outbound directions, so a forward rule is
// rejected with the ErrUnsupportedForward sentinel.
_, err = fw.MarshallFWRule("", &Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept})
require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected")
require.False(t, fw.Capabilities().Forward, "windows firewall does not advertise forward support")
}
// A named ICMPv6 type must resolve through the ICMPv6 table, not the ICMPv4 one:
// several names (e.g. echo-request) map to different numbers per family. Windows
// stores types numerically so this path is dormant in practice, but the decode
// must still be family-correct.
func TestWFICMPv6NamedType(t *testing.T) {
fw := &WF{rulePrefix: "test"}
fr := wapi.FWRule{
Protocol: wapi.NET_FW_IP_PROTOCOL_ICMPv6,
ICMPTypesAndCodes: "echo-request:*",
Action: wapi.NET_FW_ACTION_ALLOW,
Direction: wapi.NET_FW_RULE_DIR_IN,
}
r := fw.UnmarshallFWRule(fr)
require.NotNil(t, r)
require.NotNil(t, r.ICMPType)
require.Equal(t, uint8(128), *r.ICMPType, "ICMPv6 echo-request is type 128, not the ICMPv4 value 8")
}
func TestWFUnsupportedLogLimitAndNAT(t *testing.T) {
ctx := context.Background()
fw := &WF{rulePrefix: "test"}
require.Error(t, fw.AddRule(ctx, "", &Rule{Port: 22, Proto: TCP, Action: Accept, Log: true}),
"WFP should reject logging")
require.Error(t, fw.AddRule(ctx, "", &Rule{Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}),
"WFP should reject rate limiting")
nat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "1.2.3.4", ToPort: 8080}
require.Error(t, fw.AddNATRule(ctx, "", nat), "WFP should reject NAT")
require.Error(t, fw.RemoveNATRule(ctx, "", nat), "WFP should reject NAT")
_, err := fw.GetNATRules(ctx, "")
require.Error(t, err, "WFP should reject NAT listing")
}
// decodeAddress must surface a Windows rule that carries a comma-separated
// address list (built-in rules commonly do) by decoding its first entry, rather
// than erroring and letting UnmarshallFWRule drop the whole rule.
func TestWFDecodeAddressMultiValue(t *testing.T) {
fw := &WF{rulePrefix: "test"}
got, err := fw.decodeAddress("192.168.1.1,10.0.0.1")
require.NoError(t, err, "a comma-separated list must not be rejected")
require.Equal(t, "192.168.1.1", got, "the first address represents the rule")
got, err = fw.decodeAddress("2001:db8::1, 2001:db8::2")
require.NoError(t, err)
require.Equal(t, "2001:db8::1", got)
}
// decodeAddress must parse an IPv6 address in netmask notation, not only IPv4,
// and must reject an address/netmask family mismatch.
func TestWFDecodeAddressNetmask(t *testing.T) {
fw := &WF{rulePrefix: "test"}
cases := []struct{ in, want string }{
{"192.168.1.5/255.255.255.0", "192.168.1.0/24"}, // IPv4 netmask
{"192.168.1.5/24", "192.168.1.0/24"}, // IPv4 CIDR
{"2001:db8::/ffff:ffff::", "2001:db8::/32"}, // IPv6 netmask
{"2001:db8::/32", "2001:db8::/32"}, // IPv6 CIDR
}
for _, c := range cases {
got, err := fw.decodeAddress(c.in)
require.NoError(t, err, "decodeAddress(%q)", c.in)
require.Equal(t, c.want, got, "decodeAddress(%q)", c.in)
}
// An address/netmask family mismatch is rejected.
_, err := fw.decodeAddress("192.168.1.0/ffff::")
require.Error(t, err, "a v4 address with a v6 netmask must be rejected")
}
// UnmarshallFWRule must drop a rule scoped by an attribute the Rule model cannot
// represent — an application path, a Windows service, or a specific interface-type
// category — rather than decode it into a bare, unscoped rule. Windows ships many
// program- and service-scoped built-in rules; surfacing one as a match-all rule
// would let it collide (via EqualBase) with a genuinely bare rule and be reconciled
// or removed as if identical. A rule carrying none of those scopes still decodes.
func TestWFUnmarshallDropsUnrepresentableScope(t *testing.T) {
fw := &WF{rulePrefix: "test"}
base := wapi.FWRule{
Protocol: wapi.NET_FW_IP_PROTOCOL_TCP,
Action: wapi.NET_FW_ACTION_ALLOW,
Direction: wapi.NET_FW_RULE_DIR_IN,
}
// Scoped by an unrepresentable attribute: dropped from the view.
dropped := []wapi.FWRule{
func() wapi.FWRule { r := base; r.ApplicationName = `C:\prog.exe`; return r }(),
func() wapi.FWRule { r := base; r.ServiceName = "Spooler"; return r }(),
func() wapi.FWRule { r := base; r.InterfaceTypes = "Wireless"; return r }(),
func() wapi.FWRule { r := base; r.InterfaceTypes = "Lan, Wireless"; return r }(),
}
for _, fr := range dropped {
require.Nil(t, fw.UnmarshallFWRule(fr),
"a rule scoped by an unrepresentable attribute must be dropped: %+v", fr)
}
// No unrepresentable scope (interface-type "All" is the all-interfaces default):
// the bare rule still decodes.
for _, fr := range []wapi.FWRule{
base,
func() wapi.FWRule { r := base; r.InterfaceTypes = "All"; return r }(),
} {
parsed := fw.UnmarshallFWRule(fr)
require.NotNil(t, parsed, "a rule with no unrepresentable scope must decode: %+v", fr)
require.Equal(t, TCP, parsed.Proto)
require.Equal(t, Accept, parsed.Action)
}
}
// profileMatches must scope a single-zone query/removal to exactly that zone's
// profile bit — never to an all-profiles rule or a foreign multi-profile rule —
// so a zone-scoped GetRules/RemoveRule/Sync can never affect another zone. Only an
// unfiltered ("" zone) query is universal.
func TestWFProfileMatchesExcludesAllProfilesRule(t *testing.T) {
fw := new(WF)
privateFilter, ok := fw.profileFilter("private")
require.True(t, ok)
publicFilter, ok := fw.profileFilter("public")
require.True(t, ok)
cases := []struct {
name string
rulesProfiles int32
filterProfile int32
useFilter bool
want bool
}{
{
name: "exact zone match",
rulesProfiles: privateFilter,
filterProfile: privateFilter,
useFilter: true,
want: true,
},
{
name: "different zone does not match",
rulesProfiles: publicFilter,
filterProfile: privateFilter,
useFilter: true,
want: false,
},
{
name: "an all-profiles rule must not match a single-zone filter",
rulesProfiles: wapi.NET_FW_PROFILE2_ALL,
filterProfile: privateFilter,
useFilter: true,
want: false,
},
{
name: "a foreign rule spanning more than one profile must not match a single-zone filter",
rulesProfiles: privateFilter | publicFilter,
filterProfile: privateFilter,
useFilter: true,
want: false,
},
{
name: "an unfiltered (all-zones) query matches an all-profiles rule",
rulesProfiles: wapi.NET_FW_PROFILE2_ALL,
filterProfile: 0,
useFilter: false,
want: true,
},
{
name: "an unfiltered (all-zones) query matches a single-profile rule too",
rulesProfiles: privateFilter,
filterProfile: 0,
useFilter: false,
want: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := fw.profileMatches(c.rulesProfiles, c.filterProfile, c.useFilter)
require.Equal(t, c.want, got)
})
}
}