package firewall import ( "context" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" ) // A TCPUDP 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 TestCSFTCPUDPRejectFansOut(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: TCPUDP, 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 TCPUDP 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 TCPUDP rule, and be fully // removed by one RemoveRule. Before the fix the add/remove matcher compared // TCPUDP against the concrete-protocol lines exactly, so re-adds duplicated // the pair and removal was a silent no-op. func TestCSFTCPUDPPortDenyRoundTrip(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: TCPUDP, Port: 80, Action: Drop} // Add fans the TCPUDP 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 TCPUDP 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 TCPUDP deny must not duplicate its udp line") // The fanned lines read back as their own rules and cover the TCPUDP deny. parsed, err := fw.ParseIPList(path, Drop) require.NoError(t, err) require.True(t, deny.CoveredBy(parsed), "the tcp+udp deny lines must cover the TCPUDP rule") for _, g := range parsed { require.True(t, deny.Covers(g), "a fanned line must not widen the rule: %+v", g) } // 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 TCPUDP 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() // IPv6 enabled, so the deny fans out across families and the missing v6 line heals. fw := &CSF{ipv6Enabled: true} 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 TCPUDP 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: TCPUDP, 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 TCPUDP cannot be expressed as a // single line: csf.pl defaults a protocol-less line to tcp, so udp would be silently // left open. AddRule must therefore fan the rule into a tcp rule and a udp rule // before it reaches MarshalAdvRule, each of which marshals to its own line. func TestCSFAdvRuleTCPUDPWithAddressFansOut(t *testing.T) { fw := new(CSF) both := &Rule{Family: IPv4, Proto: TCPUDP, Port: 443, Source: "192.0.2.10", Action: Drop} subs := expandProtocols(both) require.Len(t, subs, 2, "a TCPUDP rule must fan out before it is marshalled") var lines []string for _, sub := range subs { lines = append(lines, fw.MarshalAdvRule(sub)) } require.Equal(t, []string{"tcp|in|d=443|s=192.0.2.10", "udp|in|d=443|s=192.0.2.10"}, lines, "each transport must get its own line so udp is not left open") } // 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). AddRule must route this // shape to the raw-iptables hook rather than let MarshalAdvRule emit an // unenforceable line. func TestCSFAdvRuleAddressWithoutPortRouted(t *testing.T) { fw := new(CSF) require.True(t, hostNeedsHook(&Rule{Family: IPv4, Proto: TCP, Source: "192.0.2.10", Action: Drop}), "a tcp host with no port must go to the hook, not an advanced line") require.True(t, hostNeedsHook(&Rule{Family: IPv4, Proto: UDP, Destination: "192.0.2.10", Action: Accept}), "a udp host with no port must go to the hook, not an advanced line") // An ICMP rule with an address and no type takes its own route: csf's linefilter // would consume the address as the icmp-type field, so it goes to the hook, whose // iptables rule needs no type. icmp := &Rule{Family: IPv4, Proto: ICMP, Source: "192.0.2.10", Action: Accept} require.False(t, hostNeedsHook(icmp), "icmp keeps its own handling") require.True(t, fw.needsHook(icmp), "a typeless icmp host must be routed to the hook") } 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 := fw.MarshalAdvRule(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") // An ICMPv6 rule has no advanced-line form at all and never reaches the // marshaller: ruleNeedsHook routes it to the raw-iptables hook. (An address-less // port rule has no advanced form either; it lands in a csf.conf port list or the // csf.deny fan-out, covered by TestCSFTCPUDPRejectFansOut.) require.True(t, ruleNeedsHook(&Rule{Proto: ICMPv6, Source: "2001:db8::1", Action: Accept}), "an icmpv6 rule must be routed to the hook, never marshalled as an advanced line") } 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 := fw.MarshalAdvRule(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) } // The single port-flow field holds one port match, so a source port matched with // a destination port has no advanced-line form; neither does an address-less // source port, since an advanced line requires an address. AddRule routes both to // the raw-iptables hook rather than marshalling them. require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}), "a dual-port rule must be routed to the hook") require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}), "an address-less source-port rule must be routed to the hook") } // Only an ICMP rule with exactly one address and a concrete type is a csf advanced // rule; every other ICMP shape is routed to the raw-iptables hook, which needs // neither. csf's linefilter would otherwise consume the address as the icmp-type // field and silently drop the rule, while the library reported it enforced. func TestCSFICMPRouting(t *testing.T) { fw := new(CSF) // With one address and 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.True(t, fw.nativeICMP(typed)) require.False(t, fw.needsHook(typed), "a typed icmp host is a native advanced rule") line := fw.MarshalAdvRule(typed) 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) // Every other ICMP shape has no advanced-line form, so it is written to the hook // rather than rejected: iptables expresses each directly. hooked := []*Rule{ {Proto: ICMP, Source: "1.2.3.4", Action: Accept}, // address, no type {Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, // type, no address {Proto: ICMP, Action: Drop}, // neither {Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}, // both addresses } h := &hookScript{} for _, r := range hooked { require.False(t, fw.nativeICMP(r), "expected non-native icmp for %+v", *r) require.True(t, fw.needsHook(r), "expected hook routing for %+v", *r) lines, err := h.rulesToLines(r) require.NoError(t, err, "the hook must be able to render %+v", *r) require.Len(t, lines, 1) require.Contains(t, lines[0], "iptables -A ") require.Contains(t, lines[0], "-p icmp") } } 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 onto // CONNLIMIT. Every other connection limit is written to the hook's `-m connlimit` // match instead of being rejected, since iptables expresses each directly. native := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}} require.True(t, fw.isConnLimitRule(native)) require.False(t, fw.needsHook(native), "a native connlimit belongs in csf.conf") hooked := []*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 {Proto: TCP, Port: 80, Direction: DirOutput, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // outbound } h := &hookScript{} for _, r := range hooked { require.False(t, fw.isConnLimitRule(r), "expected non-native connlimit for %+v", *r) require.True(t, fw.needsHook(r), "expected hook routing for %+v", *r) lines, err := h.rulesToLines(r) require.NoError(t, err, "the hook must be able to render %+v", *r) require.NotEmpty(t, lines) for _, line := range lines { require.Contains(t, line, "-m connlimit --connlimit-above 5") } } } // 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.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), so AddRule // routes it to the raw-iptables hook rather than silently dropping the destination. // The portless bare form takes the same path one predicate earlier (hostNeedsHook). func TestCSFDualAddressRouted(t *testing.T) { require.True(t, advRuleNeedsHook(&Rule{Proto: TCP, Port: 80, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}), "a ported dual-address rule must be routed to the hook, never marshalled as an advanced line") } // csf's port lists are TCP_IN/UDP_IN. A port on a concrete protocol they cannot hold // (sctp) is written to the hook, whose iptables rule matches it directly. A port on // ProtocolAny is expressible nowhere — it matches every IP protocol, and iptables has // no protocol-less port match — so it is the one shape csf still rejects. func TestCSFPortProtoGuard(t *testing.T) { fw := new(CSF) // An sctp port goes to the hook rather than into both csf.conf lists. sctp := &Rule{Proto: SCTP, Port: 80, Action: Accept} require.True(t, fw.needsHook(sctp), "an sctp port must be routed to the hook") lines, err := (&hookScript{}).rulesToLines(sctp) require.NoError(t, err) for _, line := range lines { require.Contains(t, line, "-p sctp") require.Contains(t, line, "--dport 80") } // An any-protocol port has no form in csf's config nor in iptables, so the native // path rejects it as unsupported. anyProto := &Rule{Port: 80, Action: Accept} require.False(t, fw.needsHook(anyProto), "an any-protocol port has no hook form either") require.ErrorIs(t, fw.AddRule(context.Background(), "", anyProto), ErrUnsupported, "an any-protocol port must be rejected") // The shapes csf's own config carries stay off the hook. require.False(t, fw.needsHook(&Rule{Proto: TCP, Port: 80, Action: Accept})) require.False(t, fw.needsHook(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept})) } // csf.conf's CONNLIMIT is a single dual-stack config key (it caps both v4 and v6 // connections; there is no separate v6 variant), 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. 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 a family-neutral rule // writes one line per family, which cover it between them. func TestCSFPortOnlyRejectRoundTrip(t *testing.T) { // IPv6 enabled, so a family-neutral reject writes a line per family. fw := &CSF{ipv6Enabled: true} 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) // A concrete-family rule is one line; a family-neutral one is a line per family. wantRows := 1 if rule.impliedFamily() == FamilyAny { wantRows = 2 } got, err := fw.ParseIPList(deny, Reject) require.NoError(t, err) require.Len(t, got, wantRows, "port-only reject (%s) must round-trip to %d row(s)", rule.Family, wantRows) require.True(t, rule.CoveredBy(got), "read-back rows must cover the written rule: %+v", got) for _, g := range got { require.True(t, rule.Covers(g), "read-back row must not widen the written rule: %+v", g) } // 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 — EditIPList matches an existing line 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 — EditIPList's EqualForDedup/ // EqualForRemoval gate must not disturb the FamilyAny case. func TestCSFFamilyAnyAdvDenyRoundTrip(t *testing.T) { ctx := context.Background() // IPv6 enabled, so a FamilyAny deny fans out to both placeholder lines. fw := &CSF{ipv6Enabled: true} 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") } // 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 neither a TCPUDP/IPv4 rule nor // its IPv6 twin may be reported as present against them — treating the pair as one // both-transports rule drops a family's coverage and makes Sync churn forever. func TestCSFCrossFamilyPairCoversNeitherTransportPair(t *testing.T) { stored := []*Rule{ {Family: IPv4, Proto: TCP, Port: 53, Action: Accept}, {Family: IPv6, Proto: UDP, Port: 53, Action: Accept}, } require.False(t, (&Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored), "udp/IPv6 must not stand in for the missing udp/IPv4 open") require.False(t, (&Rule{Family: IPv6, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored), "tcp/IPv4 must not stand in for the missing tcp/IPv6 open") require.False(t, (&Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored)) // Each stored rule still covers exactly its own cell. require.True(t, (&Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}).CoveredBy(stored)) require.True(t, (&Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept}).CoveredBy(stored)) } // A same-family tcp/udp pair — the fanned-out form csf writes for one TCPUDP port // rule — does cover that rule. func TestCSFSameFamilyPairCoversTCPUDP(t *testing.T) { stored := []*Rule{ {Family: IPv4, Proto: TCP, Port: 53, Action: Accept}, {Family: IPv4, Proto: UDP, Port: 53, Action: Accept}, } require.True(t, (&Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored)) } // 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") } // With csf.conf's IPV6 off, csf installs no IPv6 rule from its config, so a // family-neutral port-only deny must be written as the IPv4 line alone. An IPv6 // placeholder line would sit inert in csf.deny and read back as an IPv6 rule csf does // not enforce and AddRule would reject. Removal still matches the target against every // line, so a v6 line written while IPv6 was on is swept regardless. func TestCSFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) { ctx := context.Background() dir := t.TempDir() off := new(CSF) path := filepath.Join(dir, "csf.deny") require.NoError(t, os.WriteFile(path, nil, 0644)) deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop} require.NoError(t, off.EditIPList(ctx, path, Drop, deny, false)) data, err := os.ReadFile(path) require.NoError(t, err) require.Contains(t, string(data), "tcp|in|d=80|s=0.0.0.0/0", "the IPv4 line must be written") require.NotContains(t, string(data), "::/0", "no IPv6 line may be written while csf's IPv6 handling is off") // The rows read back cover the rule, and only for the family csf enforces. got, err := off.ParseIPList(path, Drop) require.NoError(t, err) require.Len(t, got, 1) require.Equal(t, IPv4, got[0].impliedFamily()) // A rule pinned to IPv6 still writes its line: the ipv6Unavailable gate stops a // fresh add, and Restore bypasses that gate on purpose to reproduce a snapshot. v6path := filepath.Join(dir, "csf.deny.v6") require.NoError(t, os.WriteFile(v6path, nil, 0644)) v6 := &Rule{Family: IPv6, Proto: TCP, Port: 80, Action: Drop} require.NoError(t, off.EditIPList(ctx, v6path, Drop, v6, false)) data, err = os.ReadFile(v6path) require.NoError(t, err) require.Contains(t, string(data), "tcp|in|d=80|s=::/0") // Switching IPv6 off must not strand the v6 line written while it was on. on := &CSF{ipv6Enabled: true} bothPath := filepath.Join(dir, "csf.deny.both") require.NoError(t, os.WriteFile(bothPath, nil, 0644)) require.NoError(t, on.EditIPList(ctx, bothPath, Drop, deny, false)) data, err = os.ReadFile(bothPath) require.NoError(t, err) require.Contains(t, string(data), "::/0") require.NoError(t, off.EditIPList(ctx, bothPath, Drop, deny, true)) data, err = os.ReadFile(bothPath) require.NoError(t, err) require.NotContains(t, string(data), "d=80", "removal must sweep the stale IPv6 line even with IPv6 off") }