package firewall import ( "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") } // TestWFTCPUDPProtocol verifies the merged TCPUDP protocol axis. A WFP filter // carries one protocol number, so a TCPUDP rule has no single-filter form: it must // be fanned out into a tcp filter and a udp filter on write and collapse back on // read. The row-level marshaller rejects a TCPUDP rule with a clear message, before // the more obscure tcp/udp-only port check, and ProtocolAny+ports stays rejected. func TestWFTCPUDPProtocol(t *testing.T) { fw := &WF{rulePrefix: "test"} // A TCPUDP rule reaching the row-level marshaller is rejected: the fan-out was // skipped. The guard fires before the port checks so its message wins. _, err := fw.MarshallFWRule("", &Rule{Proto: TCPUDP, Port: 80, Action: Accept}) require.Error(t, err, "a TCPUDP rule must be rejected at the row-level marshaller") require.Contains(t, err.Error(), "tcpudp", "the guard message must name the tcpudp protocol") // A portless TCPUDP rule is rejected for the same reason. _, err = fw.MarshallFWRule("", &Rule{Proto: TCPUDP, Action: Accept}) require.Error(t, err, "a portless TCPUDP rule must still be rejected") // ProtocolAny with a port stays rejected: ProtocolAny matches every IP protocol // and carries no ports, so the port cannot be honored. _, err = fw.MarshallFWRule("", &Rule{Proto: ProtocolAny, Port: 80, Action: Accept}) require.Error(t, err, "ProtocolAny with a port must be rejected") // SCTP with a port stays rejected with the ErrUnsupported sentinel: WFP matches // ports only for tcp and udp. _, err = fw.MarshallFWRule("", &Rule{Proto: SCTP, Port: 9000, Action: Accept}) require.ErrorIs(t, err, ErrUnsupported, "a port on SCTP must be rejected as unsupported") // expandProtocols fans a TCPUDP rule into a tcp row and a udp row, each of which // marshals cleanly and reads back as its concrete transport. base := &Rule{Proto: TCPUDP, Port: 22, Action: Accept} subs := expandProtocols(base) require.Len(t, subs, 2, "a TCPUDP rule fans into two concrete-transport rows") gotProtos := map[Protocol]bool{} for _, sub := range subs { fr, err := fw.MarshallFWRule("", sub) require.NoError(t, err, "an expanded row must marshal: %+v", *sub) parsed := fw.UnmarshallFWRule(*fr) require.NotNil(t, parsed, "an expanded row must round-trip: %+v", *sub) require.True(t, parsed.Equal(sub, true), "round-trip mismatch: %+v vs %+v", *sub, parsed) gotProtos[parsed.Proto] = true } require.True(t, gotProtos[TCP] && gotProtos[UDP], "the fan-out must yield one tcp and one udp row") // The two filters stay two rules on read — a WFP filter carries one protocol // number, so that is the firewall's actual state — but together they cover the // TCPUDP rule that was written, so Sync does not re-add it. stored := []*Rule{ {Proto: TCP, Port: 22, Action: Accept}, {Proto: UDP, Port: 22, Action: Accept}, } both := &Rule{Proto: TCPUDP, Port: 22, Action: Accept} require.True(t, both.CoveredBy(stored), "the tcp+udp filter pair covers the TCPUDP rule") require.False(t, both.CoveredBy(stored[:1]), "the tcp filter alone leaves udp uncovered") } // 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") } // 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) }) } }