FirewallD - Best practices for zone-based configuration

I’ve been using FirewallD for years now, and it’s still not clear to me how the developers intended it to be configured, particularly when a zone’s target is set to default. Here’s what I usually do:

Let’s say source A needs access to https. I would create a new zone for source A and add https:

firewall-cmd --permanent --new-zone zone-a
firewall-cmd --permanent --zone zone-a --add-source 1.0.0.0/24
firewall-cmd --permanent --zone zone-a --add-service https
firewall-cmd --reload

Let’s say source B needs access to mysql. I would create a zone-b for source B just like I did for source A above, except add the mysql service to it. If the source has a large list of IP addresses or networks, I might organize those into ipsets before adding the ipsets to a zone, but the above example is the gist of what I’ve always done. I really like organizing the sources of the traffic I need to control into zones based on the role that source plays in the network (e.g. sources for different VLANs, categories of servers, vendors, etc.) Note that I always set AllowZoneDrifting=no in firewalld.conf.

I always leave my zones’ targets set to default, which is where all my confusion is coming from. If I set a zone’s target to ACCEPT, DROP, or %%REJECT%%, any traffic matching the zone’s sources OR ports/services will be accepted, dropped, or rejected. Using my example above, if I were to set zone-a’s target to ACCEPT, traffic from 1.0.0.0/24 would be accepted, as well as any traffic destined to port 443. It wouldn’t only allow traffic from 1.0.0.0/24 to port 443, it would also allow traffic from 1.0.0.0/24 to ANY port, and also allow traffic from ANY source to port 443. This has always seemed very odd to me, and I can’t imagine a situation where I would want this. This is why I’ve always left a zone’s target set to default, which will only accept traffic from the zone’s sources to the zone’s ports/services, and reject everything else.

While the default target seems to get me what I want, the true behavior as documented is incredibly confusing. I see people around the internet saying it’s basically the same as %%REJECT%%. If it were the same as %%REJECT%%, wouldn’t traffic from networks defined in the zone’s sources be rejected? There’s also this GitHub post that seems to get referenced whenever this topic comes up. This post is a suggested clarification to the firewalld.zone man page, which states that the default target will reject, except in three specific scenarios. None of the listed scenarios are the scenario I frequently rely on where traffic gets accepted if the source matches the zone’s sources and the destination matches zone’s ports/services. According to this documentation, shouldn’t traffic from my zone’s sources be getting rejected? In fact, it’s traffic that doesn’t match my zone’s sources or services that gets rejected.

The last thing I’m struggling to understand is FirewallD’s lack of an obvious implicit deny rule. With traditional firewall configs made up of a single list of rules that traffic matches, there’s typically an implicit deny at the bottom of the list for traffic that doesn’t match any of the rules. For iptables, this is where you can decide whether or not you want traffic to be dropped or rejected. I’m in a situation now where I need to change FirewallD’s default %%REJECT%% behavior to DROP instead, and I have no idea how to do that. I’ve seen some discussions online say it’s impossible. I’m sure this has to do with me not understanding the default target.

My wall of text boils down to the following questions:

  1. To allow traffic from a specific set of source networks to a set of ports, is my method of using a zone to correlate the source networks with the destination ports best practice?
  2. What is the intention of the default target? Is there behavior that the documentation doesn’t outline, or is my scenario actually mentioned in the documentation and I’m misinterpreting it?
  3. What is FirewallD’s version of an implicit deny rule? How can I make it DROP by default instead of %%REJECT%%?

I appreciate anyone who takes the time to read this. :slight_smile:

1 Like

The default is probably all you are ever going to use. So unless you have rules for source IP’s, ports or services configured, then the traffic will not be allowed. Each of the zones will be evaluated, and then everything is dropped if there are no matches. Most likely that the zone drop is configured exactly for this purpose and is used instead of reject unless it has been overriden by another zone that is rejecting.

You will notice that the trusted zone has a default of accept. So basically here I can configured the IP addresses I know are trusted, that I want to have access to all ports on my server. Usually though, I started to separate this out, since before this I had my Home IP address and Office IP address in the trusted zone. But now I put them in the home and work zones. But that means I also need to configure all the ports/services that those IP’s need access to. By using the trusted zone, it just means you can do less configuration by not having to specify services/ports, etc.

Iptables had similar as well, by default it always allowed, requiring the last rule in a zone being the drop/reject one. It was possible to change that behaviour in iptables so that instead of ACCEPT to change it to DROP. Then all you needed to do was add the rules for access, and not need a drop rule at the end. So similar in a way to how firewalld is already configured.

My Fortigate firewall has a global drop rule, so I don’t need to configure one as the last rule in each zone. By using default in firewalld as the target, you are not requiring to create reject/drop rules, because it already does that unless you have configured rules to allow access.

2 Likes

First, FirewallD is a front-end. The actual rules in the kernel are now in nf_tables. One can see them with:

sudo nft list ruleset

man firewall-cmd writes about zone target ‘default’:

default is similar to REJECT, but it implicitly allows ICMP packets.

(policies have bit different targets: CONTINUE, ACCEPT, DROP, REJECT)


Lets test/verify that:

# firewall-cmd --info-zone=public
public (active)
  target: default
...

# nft list ruleset > def
# firewall-cmd --set-target=REJECT --permanent
# firewall-cmd --reload
# firewall-cmd --info-zone=public
public (active)
  target: %%REJECT%%
...

# nft list ruleset > rej
# diff def rej
139d138
< 		meta l4proto { icmp, ipv6-icmp } accept

Ok, the ‘default’ had one more rule than the REJECT. More context:

chain filter_IN_public {					chain filter_IN_public {
	jump filter_INPUT_POLICIES_pre				jump filter_INPUT_POLICIES_pre
	jump filter_IN_public_pre					jump filter_IN_public_pre
	jump filter_IN_public_log					jump filter_IN_public_log
	jump filter_IN_public_deny					jump filter_IN_public_deny
	jump filter_IN_public_allow					jump filter_IN_public_allow
	jump filter_IN_public_post					jump filter_IN_public_post
	jump filter_INPUT_POLICIES_post				jump filter_INPUT_POLICIES_post
	meta l4proto { icmp, ipv6-icmp } accept	<
	reject with icmpx admin-prohibited			reject with icmpx admin-prohibited
	}										 }

Lets try from ‘default’ to ‘ACCEPT’:

chain filter_IN_public {					chain filter_IN_public {
	jump filter_INPUT_POLICIES_pre				jump filter_INPUT_POLICIES_pre
	jump filter_IN_public_pre					jump filter_IN_public_pre
	jump filter_IN_public_log					jump filter_IN_public_log
	jump filter_IN_public_deny					jump filter_IN_public_deny
	jump filter_IN_public_allow					jump filter_IN_public_allow
	jump filter_IN_public_post					jump filter_IN_public_post
	jump filter_INPUT_POLICIES_post				jump filter_INPUT_POLICIES_post
	meta l4proto { icmp, ipv6-icmp } accept	|	accept
	reject with icmpx admin-prohibited	    <
}											 }

chain filter_FWD_public {					chain filter_FWD_public {
	jump filter_FORWARD_POLICIES_pre			jump filter_FORWARD_POLICIES_pre
	jump filter_FWD_public_pre					jump filter_FWD_public_pre
	jump filter_FWD_public_log					jump filter_FWD_public_log
	jump filter_FWD_public_deny					jump filter_FWD_public_deny
	jump filter_FWD_public_allow				jump filter_FWD_public_allow
	jump filter_FWD_public_post					jump filter_FWD_public_post
	jump filter_FORWARD_POLICIES_post			jump filter_FORWARD_POLICIES_post
	reject with icmpx admin-prohibited	  |		accept
}											}

Notice how in all three cases the last rule(s) in chain filter_IN_public and chain filter_FWD_public handle all the packets that no explicitly set rule has handled?
(If one would allow the zone drift, then the ‘default’ would not have catch-all rules.)

In other words, even though I have rule 34:

chain filter_INPUT { # handle 22
	type filter hook input priority filter + 10; policy accept;
	ct state { established, related } accept # handle 26
	ct status dnat accept # handle 27
	iifname "lo" accept # handle 28
	ct state invalid drop # handle 29
	jump filter_INPUT_ZONES # handle 33
	reject with icmpx admin-prohibited # handle 34
}

nothing should reach it as the rule 33 leads to the filter_IN_public (in my system).


A zone is two things:

  • Group of machines. For example, the 1.0.0.0/24
  • Rules that apply to that group

1.0.0.0/24 must be able to access https and 2.0.0.0/24 must be able to access mysql is easy: two zones.
If 1.0.0.42 must be able to access both https and mysql, then there are three zones. Furthermore, the “only https” zone cannot be set 1.0.0.0/24 as source because that range contains 1.0.0.42. You need something more complex (set?) to cover 1.0.0.0-1.0.0.41, 1.0.0.43-1.0.0.255.


If the ruleset is “really simple” (and most importantly “static”), then use of nftables.service instead of firewalld.service might be an option. Or, one could go with the system roles way to configure firewall Chapter 10. Configuring firewalld by using RHEL system roles | Automating system administration by using RHEL system roles | Red Hat Enterprise Linux | 8 | Red Hat Documentation (if it can set the FirewallD or nftables for you as you desire).

4 Likes

@iwalker @jlehtone Thanks so much for your responses. You’ve affirmed that I have been doing this correctly. Looking at the nftables rules FirewallD generates really is the best way to conceptualize this.

@jlehtone My nftables rules look different than yours, and I think for the most part that’s okay, but there is one major discrepancy I’d like to talk about.

On a fresh Rocky Linux 8 VM, this is my chain filter_IN_public:

chain filter_IN_public {
  jump filter_IN_public_pre
  jump filter_IN_public_log
  jump filter_IN_public_deny
  jump filter_IN_public_allow
  jump filter_IN_public_post
  meta l4proto { icmp, ipv6-icmp } accept
}

In your example, you point out that the final reject of chain filter_INPUT is never reached because of the jump filter_INPUT_ZONES. I understood that to be because chain filter_IN_public (and chains for other zones) all have their own reject rules that the traffic will match. But my zone chains do not have this reject rule. So, in my case, is the reject in chain filter_INPUT the rule that is rejecting traffic?

This brings me to my next question: Is there a way to have FirewallD make that final reject rule a drop rule instead?

My bad. My output was from el9. I don’t have el8 systems with FirewallD.
FirewallD in el8 is based on version 0.9.11, while in el9 it is based on 1.3.4; they create bit different rulesets.

I was incorrect about the zone drift as well. Lets try again:

chain filter_INPUT {
	...
	jump filter_INPUT_ZONES             # r1
	reject with icmpx admin-prohibited  # r2, final catch-all
}

chain filter_INPUT_ZONES {
	iifname "enp7s0" goto filter_IN_my  # r3, note the "goto"
	goto filter_IN_public               # r4, default zone
}

chain filter_IN_my {
	...  # r5
	# no catch-all
}

chain filter_IN_public {
	...
	meta l4proto { icmp, ipv6-icmp } accept # r6
	# optional catch-all
}
  • The r1 jumps to another chain. When that chain completes, execution continues with r2
  • The r3 goto to ‘filter_IN_my’. When that chain completes, the execution jumps back to ‘filter_INPUT’. It does not continue from r4.
  • With zone drift the r3 would use jump and therefore use r4 after the ‘filter_IN_my’ completes

If you do change the target of zone to other than ‘default’, then FirewallD should add a catch-all rule to the end of the filter_IN_${zonename} even in el8. Then no packets (of that zone) will see later rules.

That is, with zone target DROP you should drop.


The apparent rationale behind the reject with icmpx admin-prohibited is to tell immediately to TCP clients that they should not wait for any other response. Otherwise (i.e. when there is no target host or the host does drop), the TCP has to wait for reply until timeout. The reject is thus more polite (but reveals that something does send ICMP replies).

I don’t (want to) know enough about FirewallD to tell whether the last rules in filter_INPUT and filter_FORWARD can be customized.

I think this answers most of my questions. Regarding changing the implicit REJECT to DROP, I didn’t get this answered, but I’ve decided I don’t actually need to change it. I wanted to change it, because FirewallD was overloading servers when getting hit by our network security scanners. I thought this was due to FirewallD responding with hundreds of rejects per second. After further testing, I realized this was because I had LogDenied=all set in firewalld.conf. I disabled this and FirewallD no longer crashes servers when hit with our network security scanners. I just thought I’d post this in case someone was having a similar problem.

Thanks, everyone!

2 Likes