296 lines
11 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|