package firewall import ( "context" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" ) // A port-only IPv6 deny carries no address (its ::/0 is synthesized on write), // so the IPV6-disabled gate must key on the implied family alone. Otherwise the // line is written, csf.pl drops it as a v6-resolving line under IPV6!=1, and the // port stays open while GetRules reports it blocked. func TestCSFIPv6UnavailablePortOnlyDeny(t *testing.T) { fw := new(CSF) // ipv6Enabled defaults to false — the IPV6-disabled case. // The regression: a port-only IPv6 deny (no Source/Destination) must be caught. require.True(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop}), "port-only IPv6 deny slipped past the IPV6-disabled gate") // A v6-address rule was already caught and must stay caught. require.True(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Drop})) // Not caught: a family-agnostic port-only deny (its 0.0.0.0/0 twin is enforced), // a plain IPv4 rule, and ICMPv6 (which routes through the raw-iptables hook). require.False(t, fw.ipv6Unavailable(&Rule{Proto: TCP, Port: 8080, Action: Drop})) require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop})) require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept})) // With IPv6 enabled the gate never fires. fw.ipv6Enabled = true require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop})) } // A ProtocolAny port-only reject must be written to csf.deny as explicit tcp and // udp advanced lines: csf's linefilter defaults a protocol-less line to -p tcp, // so a single protocol-less line would leave udp open while the library reported // the port blocked for all protocols. func TestCSFProtocolAnyRejectFansOut(t *testing.T) { ctx := context.Background() fw := new(CSF) dir := t.TempDir() path := filepath.Join(dir, "csf.deny") require.NoError(t, os.WriteFile(path, nil, 0644)) reject := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 80, Action: Reject} require.NoError(t, fw.EditIPList(ctx, path, Reject, reject, false)) data, err := os.ReadFile(path) require.NoError(t, err) text := string(data) require.Contains(t, text, "tcp|in|d=80|s=0.0.0.0/0", "tcp line must be present") require.Contains(t, text, "udp|in|d=80|s=0.0.0.0/0", "udp line must be present so udp is actually blocked") // No protocol-less line (which csf would silently treat as tcp only). for _, line := range strings.Split(text, "\n") { require.False(t, strings.HasPrefix(strings.TrimSpace(line), "in|"), "a protocol-less advanced line silently means tcp-only in csf: %q", line) } } // A port-only deny whose action is Drop (csf.conf's default DROP for an inbound // deny) must still be written: the placeholder branch keys on "not an accept", // not on Reject specifically. Before the fix it keyed on Reject, so a Drop deny // wrote nothing while AddRule reported success — the port stayed open. func TestCSFPortOnlyDropDenyIsWritten(t *testing.T) { ctx := context.Background() fw := new(CSF) dir := t.TempDir() path := filepath.Join(dir, "csf.deny") require.NoError(t, os.WriteFile(path, nil, 0644)) drop := &Rule{Family: IPv4, Proto: TCP, Port: 3306, Action: Drop} require.NoError(t, fw.EditIPList(ctx, path, Drop, drop, false)) data, err := os.ReadFile(path) require.NoError(t, err) require.Contains(t, string(data), "tcp|in|d=3306|s=0.0.0.0/0", "a port-only Drop deny must be written with the any-network placeholder") } // A ProtocolAny port deny is written as a tcp line and a udp line, so it must be // idempotent on re-add, read back as a single ProtocolAny rule, and be fully // removed by one RemoveRule. Before the fix the add/remove matcher compared // ProtocolAny against the concrete-protocol lines exactly, so re-adds duplicated // the pair and removal was a silent no-op. func TestCSFProtocolAnyPortDenyRoundTrip(t *testing.T) { ctx := context.Background() fw := new(CSF) dir := t.TempDir() path := filepath.Join(dir, "csf.deny") require.NoError(t, os.WriteFile(path, nil, 0644)) deny := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 80, Action: Drop} // Add fans the ProtocolAny deny out to a tcp and a udp line. require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false)) data, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=80|s=0.0.0.0/0")) require.Equal(t, 1, strings.Count(string(data), "udp|in|d=80|s=0.0.0.0/0")) // Re-adding is idempotent: neither line is duplicated. require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false)) data, err = os.ReadFile(path) require.NoError(t, err) require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=80|s=0.0.0.0/0"), "re-adding a ProtocolAny deny must not duplicate its tcp line") require.Equal(t, 1, strings.Count(string(data), "udp|in|d=80|s=0.0.0.0/0"), "re-adding a ProtocolAny deny must not duplicate its udp line") // The fanned lines read back and collapse to one ProtocolAny rule. parsed, err := fw.ParseIPList(path, Drop) require.NoError(t, err) merged := fw.mergeProtocols(mergeFamilies(parsed)) require.Len(t, merged, 1, "the tcp+udp deny lines must merge to one ProtocolAny rule") require.Equal(t, ProtocolAny, merged[0].Proto) require.True(t, merged[0].EqualBase(deny, true), "the merged rule must equal the ProtocolAny deny that was added") // A single RemoveRule must drop every fanned line. require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, true)) data, err = os.ReadFile(path) require.NoError(t, err) require.NotContains(t, string(data), "d=80", "removing a ProtocolAny deny must delete all of its fanned lines") } // A port-only deny fans out across family (and protocol), but the file may already // hold a subset of those lines — a prior single-family add, or a manual edit. The // add must heal the missing lines rather than treat the rule as fully present the // moment one fan-out line matches: otherwise the other family/protocol stays open // while the library reports the port blocked. Regression for the single-"exists" // gate that skipped the whole fan-out. func TestCSFPortOnlyDenyHealsMissingFamily(t *testing.T) { ctx := context.Background() fw := new(CSF) dir := t.TempDir() path := filepath.Join(dir, "csf.deny") // The file already has only the IPv4 fan-out line. require.NoError(t, os.WriteFile(path, []byte("tcp|in|d=80|s=0.0.0.0/0\n"), 0644)) // Adding a FamilyAny port-80 TCP deny must add the missing IPv6 line (and not // duplicate the existing IPv4 one). deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop} require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false)) data, err := os.ReadFile(path) require.NoError(t, err) text := string(data) require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"), "the pre-existing IPv4 line must be preserved, not duplicated") require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=::/0"), "the missing IPv6 line must be added so IPv6:80 is actually blocked") // A ProtocolAny deny whose udp line already exists must add the missing tcp line. path2 := filepath.Join(dir, "csf.deny2") require.NoError(t, os.WriteFile(path2, []byte("udp|in|d=53|s=0.0.0.0/0\n"), 0644)) anyDeny := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 53, Action: Drop} require.NoError(t, fw.EditIPList(ctx, path2, Drop, anyDeny, false)) data2, err := os.ReadFile(path2) require.NoError(t, err) require.Equal(t, 1, strings.Count(string(data2), "udp|in|d=53|s=0.0.0.0/0"), "the pre-existing udp line must be preserved") require.Equal(t, 1, strings.Count(string(data2), "tcp|in|d=53|s=0.0.0.0/0"), "the missing tcp line must be added so tcp:53 is actually blocked") } // A csf advanced rule with an address, a port, and ProtocolAny cannot be // expressed as a single line: csf.pl defaults a protocol-less line to tcp, so // udp would be silently left open. MarshalAdvRule must reject it rather than // under-apply it (the port-only form fans out instead, but that path has no // address to place). func TestCSFAdvRuleProtocolAnyWithAddressRejected(t *testing.T) { fw := new(CSF) _, err := fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: ProtocolAny, Port: 443, Source: "192.0.2.10", Action: Drop}) require.ErrorIs(t, err, ErrUnsupported, "an address+port rule with ProtocolAny must be rejected, not written tcp-only") // A concrete protocol on the same shape is fine. _, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Drop}) require.NoError(t, err) } // A csf tcp/udp advanced rule with an address but no port cannot be expressed: // csf.pl's linefilter reads the port-flow field by position before the address // field, so the address shifts into the port slot, gets parsed as a garbage // --sport/--dport value, and the rule is silently dropped (linefilter requires // both an address and a port match to install anything). MarshalAdvRule must // reject this shape rather than emit an unenforceable line. func TestCSFAdvRuleAddressWithoutPortRejected(t *testing.T) { fw := new(CSF) _, err := fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: TCP, Source: "192.0.2.10", Action: Drop}) require.ErrorIs(t, err, ErrUnsupported, "a tcp advanced rule with an address and no port must be rejected") _, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: UDP, Destination: "192.0.2.10", Action: Accept}) require.ErrorIs(t, err, ErrUnsupported, "a udp advanced rule with an address and no port must be rejected") // An ICMP rule with an address and no port is unaffected by this guard (it // already requires a concrete ICMP type via a separate check above). _, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: ICMP, Source: "192.0.2.10", Action: Accept}) require.Error(t, err, "expected the existing icmp-without-type guard to fire, not this one") } func TestCSFParseAdvRuleIPv6(t *testing.T) { fw := new(CSF) // An IPv6 source with a port must parse (the field separator is '|', so a // colon in the value is an IPv6 address, not a range/list separator). r := fw.ParseAdvRule("tcp|in|d=22|s=2001:db8::1", Accept) require.NotNil(t, r, "expected IPv6 advanced rule to parse") require.Equal(t, IPv6, r.Family, "expected IPv6 family") require.Equal(t, "2001:db8::1", r.Source) require.EqualValues(t, 22, r.Port) require.Equal(t, TCP, r.Proto) require.False(t, r.IsOutput()) // An IPv4 destination with a port still parses. r = fw.ParseAdvRule("tcp|out|d=80|d=192.0.2.1", Accept) require.NotNil(t, r, "expected IPv4 advanced rule to parse") require.Equal(t, IPv4, r.Family) require.Equal(t, "192.0.2.1", r.Destination) require.EqualValues(t, 80, r.Port) // A destination port range is neither a valid IP nor a single port, so it // must still be rejected. require.Nil(t, fw.ParseAdvRule("tcp|in|d=1000:2000", Accept), "expected a port range to be rejected") // A comma-separated address list is still rejected. require.Nil(t, fw.ParseAdvRule("tcp|in|s=192.0.2.1,192.0.2.2", Accept), "expected a multi-address rule to be rejected") } func TestCSFFeatureRules(t *testing.T) { fw := new(CSF) // Advanced-rule encodings. cases := []struct { rule *Rule want string }{ {&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|d=80,443|s=1.2.3.4"}, {&Rule{Proto: TCP, Ports: []PortRange{{Start: 2000, End: 3000}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|d=2000_3000|s=1.2.3.4"}, {&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "44.33.22.11", Family: IPv4, Action: Accept}, "icmp|in|d=8|s=44.33.22.11"}, {&Rule{Direction: DirOutput, Proto: UDP, Port: 53, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "udp|out|d=53|d=192.0.2.1"}, } for _, c := range cases { got, err := fw.MarshalAdvRule(c.rule) require.NoError(t, err, "failed to marshal %+v", *c.rule) require.Equal(t, c.want, got, "marshal %+v", *c.rule) parsed := fw.ParseAdvRule(got, c.rule.Action) require.NotNil(t, parsed, "failed to parse %q", got) require.True(t, parsed.Equal(c.rule, true), "round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed) } // An ICMP type given by name resolves to its number. r := fw.ParseAdvRule("icmp|in|d=ping|s=44.33.22.11", Accept) require.NotNil(t, r, "expected icmp type 8 from name ping") require.NotNil(t, r.ICMPType, "expected icmp type 8 from name ping") require.EqualValues(t, 8, *r.ICMPType, "expected icmp type 8 from name ping") // csf reuses the port position for the ICMP type in BOTH the s= and d= fields // (csf.pl maps `s=` to `--icmp-type ` for an icmp rule, just like `d=`). // A foreign rule that carries the type in s= must therefore read back as the // ICMP type, not as a nonsensical source port. r = fw.ParseAdvRule("icmp|in|s=8|d=44.33.22.11", Accept) require.NotNil(t, r, "expected icmp rule with type in s= to parse") require.NotNil(t, r.ICMPType, "expected s=8 to be read as icmp type 8") require.EqualValues(t, 8, *r.ICMPType, "expected icmp type 8 from s=8") require.False(t, r.HasSourcePorts(), "an icmp type must not be read as a source port") require.Equal(t, "44.33.22.11", r.Destination) // A colon range in an advanced rule is invalid (csf uses underscores there). require.Nil(t, fw.ParseAdvRule("tcp|in|d=1000:2000|s=1.2.3.4", Accept), "expected colon range in advanced rule to be rejected") // csf.conf port lists parse single ports and colon ranges. rules := fw.ParsePorts("20,21,30000:35000", IPv4, TCP, false) require.Len(t, rules, 3, "expected 3 port rules") require.Len(t, rules[2].Ports, 1) require.Equal(t, PortRange{Start: 30000, End: 35000}, rules[2].Ports[0], "expected a 30000-35000 range rule") // EditRulePort adds a colon range token to the matching csf.conf port list. require.Equal(t, `TCP_IN = "22,2000:3000"`, fw.EditRulePort(`TCP_IN = "22"`, "TCP_IN", "22", &Rule{Proto: TCP, Ports: []PortRange{{Start: 2000, End: 3000}}, Action: Accept}, false), "unexpected csf.conf port edit") // MarshalAdvRule rejects rules csf cannot express as advanced rules. _, err := fw.MarshalAdvRule(&Rule{Proto: ICMPv6, Source: "2001:db8::1", Action: Accept}) require.Error(t, err, "expected error marshalling an icmpv6 advanced rule") _, err = fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Action: Accept}) require.Error(t, err, "expected error marshalling an advanced rule without an address") } func TestCSFSourcePorts(t *testing.T) { fw := new(CSF) // Source ports round-trip through the s= port-flow field, including a // multiport list and an underscore range. cases := []struct { rule *Rule want string }{ {&Rule{Proto: TCP, SourcePort: 1234, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "tcp|in|s=1234|d=192.0.2.1"}, {&Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "udp|in|s=80,443|s=1.2.3.4"}, {&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 2000, End: 3000}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|s=2000_3000|s=1.2.3.4"}, } for _, c := range cases { got, err := fw.MarshalAdvRule(c.rule) require.NoError(t, err, "failed to marshal %+v", *c.rule) require.Equal(t, c.want, got, "marshal %+v", *c.rule) parsed := fw.ParseAdvRule(got, c.rule.Action) require.NotNil(t, parsed, "failed to parse %q", got) require.True(t, parsed.Equal(c.rule, true), "round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed) } // Matching both a source and a destination port is not representable. _, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}) require.Error(t, err, "expected error matching both source and destination ports") // A source-port rule with no address has nowhere to go. require.Error(t, fw.checkSourcePort(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept})) } // An ICMP advanced rule that carries an address but no concrete type must be // rejected: csf's linefilter would consume the address as the icmp-type field // and silently drop the rule, while the library reported it enforced. func TestCSFICMPRequiresType(t *testing.T) { fw := new(CSF) // Address but no type: rejected by both the validator and the marshaller. noType := &Rule{Proto: ICMP, Source: "1.2.3.4", Action: Accept} require.Error(t, fw.checkICMP(noType)) _, err := fw.MarshalAdvRule(noType) require.Error(t, err, "must not emit an ICMP advanced line with the address in the type field") // With a concrete type it is a valid advanced rule and round-trips. typed := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept} require.NoError(t, fw.checkICMP(typed)) line, err := fw.MarshalAdvRule(typed) require.NoError(t, err) require.Equal(t, "icmp|in|d=8|s=1.2.3.4", line) parsed := fw.ParseAdvRule(line, Accept) require.NotNil(t, parsed) require.True(t, parsed.Equal(typed, true), "round-trip mismatch: %q -> %+v", line, parsed) // An address-less ICMP rule is still rejected (advanced rules need an address). require.Error(t, fw.checkICMP(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept})) } func TestCSFConnLimit(t *testing.T) { fw := new(CSF) // A csf.conf CONNLIMIT value parses into per-port reject rules carrying a // per-source connection cap. csf's CONNLIMIT chain rejects the excess with a // TCP reset (-j REJECT --reject-with tcp-reset), so the action is Reject. rules := fw.ParseConnLimit("22;5,80;20") require.Len(t, rules, 2) require.Equal(t, TCP, rules[1].Proto) require.EqualValues(t, 80, rules[1].Port) require.Equal(t, Reject, rules[1].Action) require.NotNil(t, rules[1].ConnLimit) require.EqualValues(t, 20, rules[1].ConnLimit.Count) require.True(t, rules[1].ConnLimit.PerSource) // Editing the CONNLIMIT list adds, removes, and updates a port's entry. require.Equal(t, `CONNLIMIT = "22;5,80;20"`, fw.editConnLimit("22;5", 80, 20, false)) require.Equal(t, `CONNLIMIT = "80;20"`, fw.editConnLimit("22;5,80;20", 22, 5, true)) require.Equal(t, `CONNLIMIT = "80;50"`, fw.editConnLimit("80;20", 80, 50, false)) // Only a single inbound tcp port, address-less, per-source, reject rule maps. require.True(t, fw.isConnLimitRule(&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}})) bad := []*Rule{ {Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // wrong action (csf rejects, not drops) {Proto: UDP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // udp {Proto: TCP, Port: 80, Source: "1.2.3.4", Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // address {Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // range {Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: false}}, // global } for _, r := range bad { require.Error(t, fw.checkConnLimit(r), "expected reject for %+v", *r) } } // ParseConnLimit's reported Family must follow csf.conf's IPV6 setting: csf.pl // only installs the ip6tables CONNLIMIT rule when IPV6 is enabled, so on the // shipped default (IPV6="0") CONNLIMIT protects IPv4 only, not both families. func TestCSFConnLimitFamily(t *testing.T) { disabled := &CSF{ipv6Enabled: false} rules := disabled.ParseConnLimit("22;5") require.Len(t, rules, 1) require.Equal(t, IPv4, rules[0].Family, "CONNLIMIT must report IPv4-only when csf.conf IPV6 is off") enabled := &CSF{ipv6Enabled: true} rules = enabled.ParseConnLimit("22;5") require.Len(t, rules, 1) require.Equal(t, FamilyAny, rules[0].Family, "CONNLIMIT must report dual-stack (FamilyAny) when csf.conf IPV6 is on") } func TestCSFRedirectNAT(t *testing.T) { fw := new(CSF) cases := []struct { rule *NATRule want string }{ // A local port redirect (IPy = "*"). {&NATRule{Kind: Redirect, Proto: TCP, Port: 666, ToPort: 25}, "*|666|*|25|tcp"}, // A forward to another host with a fixed destination address. {&NATRule{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1", ToPort: 25, Family: IPv4}, "192.168.254.62|666|10.0.0.1|25|tcp"}, // A full-IP forward, all ports (portA/portB unset). {&NATRule{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", ToAddress: "10.0.0.1", Family: IPv4}, "192.168.254.62|*|10.0.0.1|*|tcp"}, } for _, c := range cases { got, err := fw.MarshalNATRule(c.rule) require.NoError(t, err, "failed to marshal %+v", *c.rule) require.Equal(t, c.want, got, "marshal %+v", *c.rule) parsed := fw.UnmarshalNATRule(got) require.NotNil(t, parsed, "failed to parse %q", got) require.True(t, parsed.EqualBase(c.rule), "round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed) } // csf.redirect cannot express source NAT, port ranges, source matching, or // non-tcp/udp protocols. bad := []*NATRule{ {Kind: SNAT, ToAddress: "1.2.3.4"}, {Kind: Masquerade}, {Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, ToAddress: "1.2.3.4"}, {Kind: DNAT, Proto: ICMP, ToAddress: "1.2.3.4"}, {Kind: DNAT, Proto: TCP, Source: "1.2.3.4", ToAddress: "5.6.7.8"}, } for _, r := range bad { _, err := fw.MarshalNATRule(r) require.Error(t, err, "expected error marshalling %+v", *r) } // A malformed csf.redirect line is ignored by the parser. require.Nil(t, fw.UnmarshalNATRule("nonsense|line")) } func TestCSFIPListComment(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "csf.allow") fw := &CSF{rulePrefix: "myapp"} ctx := context.Background() require.NoError(t, os.WriteFile(path, []byte( "# myapp trusted office\n"+ "tcp|in|d=22|s=10.0.0.0/24\n"+ "\n"+ "# unrelated note\n"+ "# separated by blank\n"+ "192.0.2.5\n"+ "2001:db8::1 # inline ignored\n", ), 0644)) rules, err := fw.ParseIPList(path, Accept) require.NoError(t, err) // Advanced rule keeps the comment immediately above it. adv := rules[0] require.Equal(t, "trusted office", adv.Comment) require.Equal(t, "10.0.0.0/24", adv.Source) require.EqualValues(t, 22, adv.Port) // A bare IPv4 line is one bidirectional DirAny rule carrying the accumulated comment. host := rules[1] require.Equal(t, DirAny, host.Direction) require.Equal(t, "192.0.2.5", host.Source) require.Equal(t, "unrelated note separated by blank", host.Comment) // Inline comment is ignored, not treated as a rule comment. v6 := rules[2] require.Equal(t, DirAny, v6.Direction) require.Equal(t, "", v6.Comment) require.Equal(t, "2001:db8::1", v6.Source) // Add a rule with a comment: a prefixed full-line comment is written above it. add := &Rule{Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Accept, Comment: "web"} require.NoError(t, fw.EditIPList(ctx, path, Accept, add, false)) data, err := os.ReadFile(path) require.NoError(t, err) require.Contains(t, string(data), "# myapp web\n") require.Contains(t, string(data), "tcp|in|d=443|s=192.0.2.10") // Removing the rule drops the comment line above it as well. require.NoError(t, fw.EditIPList(ctx, path, Accept, add, true)) data, err = os.ReadFile(path) require.NoError(t, err) require.NotContains(t, string(data), "# myapp web") require.NotContains(t, string(data), "192.0.2.10") // A port-only rule has nowhere to go in an IP-list file; no dangling // comment line should be written even when a comment is supplied. portOnly := &Rule{Proto: TCP, Port: 8080, Action: Accept, Comment: "not-stored"} require.NoError(t, fw.EditIPList(ctx, path, Accept, portOnly, false)) data, err = os.ReadFile(path) require.NoError(t, err) require.NotContains(t, string(data), "not-stored") // A rule appended after instructional header comments must still report // HasPrefix: the prefix tag starts a fresh comment block so header // comments are not absorbed into the rule's comment. headerPath := filepath.Join(dir, "header_csf.allow") require.NoError(t, os.WriteFile(headerPath, []byte( "# This is the csf.allow file.\n"+ "# Add hosts/rules below, one per line.\n"+ "# Format: proto|flow|port|ip\n", ), 0644)) appendRule := &Rule{Proto: TCP, Port: 3456, Source: "192.0.2.10/32", Action: Accept} require.NoError(t, fw.EditIPList(ctx, headerPath, Accept, appendRule, false)) parsed, err := fw.ParseIPList(headerPath, Accept) require.NoError(t, err) require.Len(t, parsed, 1) require.True(t, parsed[0].HasPrefix, "rule after header comments must be flagged with the prefix") require.Equal(t, "", parsed[0].Comment) } // TestCSFRemovePreservesForeignHeader verifies that removing a managed rule keeps // a foreign section header sitting directly above its prefix tag. ParseIPList // treats the tag as starting a fresh comment block, so the header is not part of // the rule's comment; removal must mirror that and not delete it. func TestCSFRemovePreservesForeignHeader(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "csf.allow") fw := &CSF{rulePrefix: "myapp"} ctx := context.Background() require.NoError(t, os.WriteFile(path, []byte( "# Section: web servers\n"+ "# myapp trusted\n"+ "192.0.2.50\n", ), 0644)) require.NoError(t, fw.EditIPList(ctx, path, Accept, &Rule{Source: "192.0.2.50", Action: Accept}, true)) data, err := os.ReadFile(path) require.NoError(t, err) got := string(data) require.NotContains(t, got, "192.0.2.50", "the managed rule must be removed") require.NotContains(t, got, "# myapp trusted", "the rule's own tag comment is removed with it") require.Contains(t, got, "# Section: web servers", "the foreign section header must be preserved") } // csf.pl's linefilter drops a csf.allow/csf.deny line (plain or advanced) that // resolves to an IPv6 address whenever csf.conf's IPV6 is not "1" (the shipped // default). ipv6Unavailable must flag exactly that shape, and only when // ipv6Enabled is false; ICMPv6 (always hook-routed) must never be flagged. func TestCSFIPv6UnavailableGate(t *testing.T) { disabled := &CSF{ipv6Enabled: false} enabled := &CSF{ipv6Enabled: true} bareV6Host := &Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept} require.True(t, disabled.ipv6Unavailable(bareV6Host), "an IPv6 rule must be blocked when csf.conf IPV6 is off") require.False(t, enabled.ipv6Unavailable(bareV6Host), "an IPv6 rule must be allowed when csf.conf IPV6 is on") bareV4Host := &Rule{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept} require.False(t, disabled.ipv6Unavailable(bareV4Host), "an IPv4 rule must never be blocked by the IPv6 gate") // ICMPv6 always routes through the raw-iptables hook, which runs outside // csf's own IPV6-gated logic, so it must not be blocked either way. icmpv6 := &Rule{Proto: ICMPv6, Source: "2001:db8::1", Action: Accept} require.False(t, disabled.ipv6Unavailable(icmpv6), "an icmpv6 rule must not be blocked by the IPv6 gate") } // csf.deny encodes no action of its own, so a rule added with Action Drop must be // found and removed by the same Drop rule rather than leaking. func TestCSFDropRuleRemovable(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "csf.deny") require.NoError(t, os.WriteFile(path, nil, 0644)) fw := new(CSF) drop := &Rule{Proto: TCP, Port: 3306, Source: "1.2.3.4", Action: Drop} require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false)) require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false)) require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, true)) data, _ := os.ReadFile(path) require.NotContains(t, string(data), "1.2.3.4", "a Drop rule must be removable by the same Drop rule") } // A ported csf advanced rule that matches both a source and a destination address // cannot be expressed (a csf advanced rule holds a single address field) and must // be rejected rather than silently dropping the destination. The portless bare // form is not tested here: AddRule diverts it to the raw-iptables hook // (hostNeedsHook), so the writer never sees it. func TestCSFDualAddressRejected(t *testing.T) { fw := new(CSF) _, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}) require.Error(t, err, "a dual-address csf advanced rule must be rejected") } // csf's port lists are TCP_IN/UDP_IN; a concrete non-tcp/udp protocol (sctp) would // wrongly be written into both, so it must error. ProtocolAny is allowed (it maps // to both lists as the faithful "any" expansion). func TestCSFPortProtoGuard(t *testing.T) { fw := new(CSF) require.Error(t, fw.checkPortProto(&Rule{Proto: SCTP, Port: 80, Action: Accept}), "sctp port must be rejected") require.NoError(t, fw.checkPortProto(&Rule{Port: 80, Action: Accept}), "ProtocolAny port is allowed for csf") require.NoError(t, fw.checkPortProto(&Rule{Proto: TCP, Port: 80, Action: Accept})) require.NoError(t, fw.checkPortProto(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept})) } // csf.conf's CONNLIMIT is a single dual-stack config key (it caps both v4 and v6 // connections; there is no v6 variant to merge), so a connection-limit rule read // from it must be FamilyAny — not IPv4 — or a FamilyAny desired connlimit rule // (the natural shape: no address, so no family is implied) never matches its own // read-back and Sync removes-and-re-adds it every reconcile, firing csf -r each // time. This mirrors the APF behavior above. func TestCSFConnLimitFamilyIsAny(t *testing.T) { // csf.pl only installs the ip6tables CONNLIMIT rule when csf.conf's IPV6 is // enabled; only then does a dual-stack FamilyAny read-back (and the // FamilyAny-desired-rule match below) hold. See TestCSFConnLimitFamily for // the IPV6-disabled (stock default) case, where CONNLIMIT is IPv4-only. f := &CSF{ipv6Enabled: true} rules := f.ParseConnLimit("80;20") require.Len(t, rules, 1) require.Equal(t, FamilyAny, rules[0].Family, "a dual-stack CONNLIMIT entry must read back as FamilyAny when csf.conf IPV6 is on") desired := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 20, PerSource: true}} require.True(t, desired.Equal(rules[0], true), "FamilyAny connlimit must equal the CSF read-back or Sync churns") } // csf.redirect only expresses a DNAT with a concrete matched destination and // either both a matched and target port or neither. MarshalNATRule must reject // the shapes csf would refuse rather than emit a line that aborts the redirect // load — a round-trip test alone would not catch it. func TestCSFDNATMarshalRejectsUnexpressible(t *testing.T) { f := new(CSF) bad := []struct { name string rule *NATRule }{ {"empty destination (ipx=*)", &NATRule{Kind: DNAT, Proto: TCP, Port: 666, ToAddress: "10.0.0.1", ToPort: 25}}, {"matched port but no target port", &NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", Port: 80, ToAddress: "5.6.7.8"}}, {"target port but no matched port", &NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", ToAddress: "5.6.7.8", ToPort: 8080}}, } for _, c := range bad { _, err := f.MarshalNATRule(c.rule) require.Errorf(t, err, "csf.redirect should reject %s", c.name) // The rejection must carry ErrUnsupportedNAT so callers (and the integration // harness's roundTripNATVariants) treat the shape as unexpressible and skip // it rather than seeing a hard error. require.ErrorIsf(t, err, ErrUnsupportedNAT, "%s should be reported as unsupported NAT", c.name) } // The two shapes csf accepts still marshal: a full-IP forward (no ports) and a // concrete port forward. full, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", ToAddress: "5.6.7.8"}) require.NoError(t, err) require.Equal(t, "1.2.3.4|*|5.6.7.8|*|tcp", full) fwd, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", Port: 80, ToAddress: "5.6.7.8", ToPort: 8080}) require.NoError(t, err) require.Equal(t, "1.2.3.4|80|5.6.7.8|8080|tcp", fwd) } // TestCSFBareProtocolRoutesToHook guards that a rule with no port and no address // (a bare protocol match) is injected through the pre-hook rather than rejected or // silently written nowhere. csf keys every native rule on a port or an address, so // such a rule maps to no csf construct; iptables expresses it directly, so addRule // diverts it to the hook (bareProtoNeedsHook). ICMP keeps its own handling. func TestCSFBareProtocolRoutesToHook(t *testing.T) { for _, r := range []*Rule{ {Proto: TCP, Action: Accept}, {Proto: ProtocolAny, Action: Accept}, {Proto: UDP, Action: Drop}, } { require.True(t, bareProtoNeedsHook(r), "a portless, addressless rule must route to the hook, not be rejected: %+v", r) } require.False(t, bareProtoNeedsHook(&Rule{Proto: ICMP, Action: Accept}), "an ICMP rule keeps its own handling and is excluded from the bare-protocol hook route") } // A port-only reject (no address) must be written so csf actually enforces it. // csf's advanced-rule handler only emits an iptables rule when the line carries a // source/destination IP alongside the port, so a bare "d=80" was parsed by csf // and then silently never applied — the port stayed open while the library // reported it blocked. The rule must be written with the "any" network as the // address (so csf enforces it) and must still round-trip and remove: parseAddr // normalizes the "any" network back to an empty address, and mergeFamilies // collapses the v4/v6 pair a family-neutral rule writes. func TestCSFPortOnlyRejectRoundTrip(t *testing.T) { fw := new(CSF) ctx := context.Background() for _, rule := range []*Rule{ {Action: Reject, Proto: TCP, Port: 80}, {Action: Reject, Proto: TCP, Port: 80, Family: IPv4}, {Action: Reject, Proto: TCP, Port: 8080, Family: IPv6}, {Action: Reject, Proto: TCP, Port: 443, Direction: DirOutput}, } { deny := filepath.Join(t.TempDir(), "csf.deny") require.NoError(t, os.WriteFile(deny, nil, 0o644)) require.NoError(t, fw.EditIPList(ctx, deny, Reject, rule, false)) // The written line must carry an address, or csf never applies it. raw, err := os.ReadFile(deny) require.NoError(t, err) require.True(t, strings.Contains(string(raw), "0.0.0.0/0") || strings.Contains(string(raw), "::/0"), "port-only reject (%s) must be written with an address so csf enforces it; got:\n%s", rule.Family, raw) // GetRules applies mergeFamilies to the parsed list; mirror it here. got, err := fw.ParseIPList(deny, Reject) require.NoError(t, err) got = mergeFamilies(got) require.Len(t, got, 1, "port-only reject (%s) must round-trip to one rule", rule.Family) require.True(t, rule.Equal(got[0], false), "read-back rule must equal the written one: %+v", got[0]) // It must also be removable (matched back on delete). require.NoError(t, fw.EditIPList(ctx, deny, Reject, rule, true)) got, err = fw.ParseIPList(deny, Reject) require.NoError(t, err) require.Len(t, got, 0, "rule (%s) must be fully removed", rule.Family) } } // A bare all-protocol host rule (address, no port) is the one portless address // shape csf.allow/csf.deny express, written as the plain address line. The // inexpressible shapes — a concrete-protocol host or a source+destination pair — // are diverted to the hook by AddRule (hostNeedsHook) and never reach this // writer, so only the legitimate write is exercised here. func TestCSFBareHostWritten(t *testing.T) { fw := new(CSF) ctx := context.Background() list := filepath.Join(t.TempDir(), "csf.allow") require.NoError(t, os.WriteFile(list, nil, 0o644)) require.NoError(t, fw.EditIPList(ctx, list, Accept, &Rule{Source: "1.2.3.4", Action: Accept}, false)) got, err := os.ReadFile(list) require.NoError(t, err) require.Contains(t, string(got), "1.2.3.4", "an any-protocol host rule must be written as a plain address") } // A port-only "any"-source deny is written to csf.deny as a family-specific // placeholder line (0.0.0.0/0 for IPv4, ::/0 for IPv6). The two lines cover // different families, so adding the IPv6 twin while the IPv4 line already exists // must write it — advMatch matches with EqualForDedup, so without the family // coverage gate the IPv6 add was silently dropped as a false duplicate, leaving // IPv6 open and making Sync churn forever. func TestCSFCrossFamilyAdvDenyBothWritten(t *testing.T) { ctx := context.Background() fw := new(CSF) dir := t.TempDir() path := filepath.Join(dir, "csf.deny") require.NoError(t, os.WriteFile(path, nil, 0644)) v4 := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop} v6 := &Rule{Family: IPv6, Proto: TCP, Port: 80, Action: Drop} require.NoError(t, fw.EditIPList(ctx, path, Drop, v4, false)) require.NoError(t, fw.EditIPList(ctx, path, Drop, v6, false)) data, err := os.ReadFile(path) require.NoError(t, err) text := string(data) require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"), "IPv4 deny line must be present") require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=::/0"), "IPv6 deny line must be present, not dropped as a false duplicate") // Removing only the IPv6 twin must leave the IPv4 line intact (family-scoped // removal must not delete the other family's line). require.NoError(t, fw.EditIPList(ctx, path, Drop, v6, true)) data, err = os.ReadFile(path) require.NoError(t, err) text = string(data) require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"), "removing IPv6 must not drop the IPv4 line") require.Equal(t, 0, strings.Count(text, "tcp|in|d=80|s=::/0"), "the IPv6 line must be removed") } // A FamilyAny port-only deny writes both placeholder lines and must still be // idempotent on re-add and fully removable — the EqualForDedup/EqualForRemoval // gate in advMatch must not disturb the FamilyAny case. func TestCSFFamilyAnyAdvDenyRoundTrip(t *testing.T) { ctx := context.Background() fw := new(CSF) dir := t.TempDir() path := filepath.Join(dir, "csf.deny") require.NoError(t, os.WriteFile(path, nil, 0644)) deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop} require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false)) // Re-add is idempotent. require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false)) data, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=22|s=0.0.0.0/0")) require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=22|s=::/0")) // One removal clears both family lines. require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, true)) data, err = os.ReadFile(path) require.NoError(t, err) require.NotContains(t, string(data), "d=22", "a FamilyAny removal must clear both placeholder lines") } // TestCSFMergeProtocolsRespectsFamily guards mergeProtocols against collapsing a // tcp/udp pair that differ in IP family. CSF expresses IPv4 and IPv6 opens through // separate config keys (TCP_IN vs TCP6_IN), so a `TCP_IN="53"` + `UDP6_IN="53"` // config produces a tcp/IPv4 rule and a udp/IPv6 rule. Those cover different // families and must NOT merge into one ProtocolAny rule — doing so drops the IPv6 // coverage from the read-back and makes Sync churn forever. func TestCSFMergeProtocolsRespectsFamily(t *testing.T) { fw := new(CSF) // tcp/IPv4 and udp/IPv6, both port 53 inbound — same in every field but proto // and family. tcpV4 := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept} udpV6 := &Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept} out := fw.mergeProtocols([]*Rule{tcpV4, udpV6}) if len(out) != 2 { t.Fatalf("a cross-family tcp/udp pair must not merge: got %d rules %+v, want 2", len(out), out) } // Both families must still be represented. var haveV4, haveV6 bool for _, r := range out { switch r.impliedFamily() { case IPv4: haveV4 = true case IPv6: haveV6 = true } } if !haveV4 || !haveV6 { t.Fatalf("both families must survive: haveV4=%v haveV6=%v (%+v)", haveV4, haveV6, out) } } // TestCSFMergeProtocolsSameFamily confirms the intended merge still happens for a // same-family tcp/udp pair (the fanned-out form of one ProtocolAny port rule). func TestCSFMergeProtocolsSameFamily(t *testing.T) { fw := new(CSF) tcp := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept} udp := &Rule{Family: IPv4, Proto: UDP, Port: 53, Action: Accept} out := fw.mergeProtocols([]*Rule{tcp, udp}) if len(out) != 1 { t.Fatalf("a same-family tcp/udp pair should merge to one rule: got %d %+v", len(out), out) } if out[0].Proto != ProtocolAny { t.Fatalf("merged rule should be ProtocolAny, got %v", out[0].Proto) } } // GetRules reports both the library's own rules and foreign ones, each tagged // with HasPrefix and with the configured prefix stripped from the surfaced comment. func TestCSFHasPrefixFlag(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "csf.allow") fw := &CSF{rulePrefix: "myapp"} require.NoError(t, os.WriteFile(path, []byte( "# myapp web\n"+ "tcp|in|d=443|s=192.0.2.10\n"+ "\n"+ "# hand-added by an admin\n"+ "tcp|in|d=22|s=10.0.0.0/24\n", ), 0644)) rules, err := fw.ParseIPList(path, Accept) require.NoError(t, err) require.Len(t, rules, 2) // Our rule: prefix stripped from the comment, flagged as carrying the prefix. require.Equal(t, "web", rules[0].Comment) require.True(t, rules[0].HasPrefix, "prefixed comment sets HasPrefix") // The admin's rule: comment surfaces unchanged, no prefix. require.Equal(t, "hand-added by an admin", rules[1].Comment) require.False(t, rules[1].HasPrefix, "a comment without the prefix is not flagged") }