Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged value spanning both transports, distinct from ProtocolAny (which matches every IP protocol and carries no port). Backends whose native syntax holds both transports in one row (nftables, ufw, apf) store and read it as one rule; the rest fan it out with expandProtocols. Removing one transport of a merged row splits it via splitMergedRow, which composes the family and protocol splits so an nftables row merged on both axes leaves a correct, non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT. Remove read-side merging. GetRules now reports the firewall's actual rows and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up separately-stored ones, so mergeFamilies, mergeDirections and their helpers are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the NATRule pair) expand a rule across family, transport and direction and decide containment cell by cell, which is what lets Sync stay a no-op against its own output whichever representation a backend chose. Extract the systemd/SysV service helpers out of the iptables backend into services.go so every Linux backend shares one implementation, and document the multi-state rule model and the coverage helpers in the README.
334 lines
13 KiB
Go
334 lines
13 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|